Skip to main content
Java · lesson 46 of 66
Open the Java page →
Java advanced Lesson 46 of 66

Also 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

#RiskJava mitigation
A01Broken access controlServer-side checks on every request, e.g. Spring Security @PreAuthorize
A02Cryptographic failuresAES-GCM, PBKDF2/BCrypt/Argon2, TLS; no MD5/SHA-1 for passwords
A03InjectionPreparedStatement, allowlists for identifiers, no shell string building
A05Security misconfigurationNo debug endpoints or default credentials in production
A06Vulnerable componentsDependabot or mvn versions:display-dependency-updates
A07Authentication failuresSecureRandom tokens, short sessions, rate limiting
A08Integrity failuresDeserialization filters, verified dependencies
A10SSRFAllowlist outbound hosts before calling HttpClient

Practice

  1. Drop the PBKDF2 iteration count from 600,000 to 1,000 and time one hash call at each setting with System.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.)

  1. 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.

  1. Add a requirePositiveAmount(BigDecimal) validator that rejects null, zero and negatives, and show why money should not be a double.
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.

Frequently Asked Questions

What is the most common security vulnerability in Java web apps?
Injection, and SQL injection in particular, remains one of the most common and damaging. It happens when user input is concatenated into a SQL string, letting the input change the query's structure. Binding values through PreparedStatement parameters keeps input as data and closes the hole; broken access control is the other top risk in the OWASP Top 10.
How should I store passwords in Java?
Never store plain text and never use a fast hash such as MD5, SHA-1 or plain SHA-256. Use a slow, salted password hashing function: PBKDF2 is built into the JDK, while BCrypt and Argon2 are available through Spring Security or Bouncy Castle. Store the salt and iteration count with the hash and compare with a constant-time check.
Why is java.util.Random not safe for tokens?
java.util.Random is a small linear congruential generator, so anyone who sees a few outputs can reconstruct its state and predict every later value. Session IDs, reset tokens and keys must come from java.security.SecureRandom, which draws on the operating system's entropy and is designed to resist prediction.
Should I write my own cryptography code in Java?
No. Use the Java Cryptography Architecture with standard, authenticated constructions such as AES-GCM, and well-audited libraries for higher-level protocols such as JWT. Most real-world crypto bugs come from home-made schemes or misused primitives, like reusing a GCM nonce or choosing ECB mode, rather than broken algorithms.
What is the difference between authentication and authorisation?
Authentication establishes who the caller is, for example by checking a password, token or certificate. Authorisation decides what that identity is allowed to do, such as whether this user may read this order. Both are needed on every request, and authorisation checks belong on the server, never only in the user interface.