CWE-326: Inadequate Encryption Strength - Java
Overview
Inadequate Encryption Strength in Java applications comes from weak algorithms, insufficient key sizes, or deprecated ciphers. The JCA (Java Cryptography Architecture) and JCE (Java Cryptography Extension) expose the insecure options alongside the secure ones, so the algorithm string passed to Cipher.getInstance() or MessageDigest.getInstance() is what decides which you get.
Common Java Vulnerability Scenarios:
- Using DES or 3DES instead of AES-256
- Implementing RSA with 1024- or 2048-bit keys instead of 3072 bits
- Using MD5 or SHA-1 for security-critical hashing
- Applying ECB mode which reveals patterns in encrypted data
- Using weak cipher suites in TLS/SSL connections
- Implementing password hashing with insufficient iterations
Java Cryptographic Landscape:
- javax.crypto: Built-in JCE for encryption/decryption
- java.security: Core security classes including MessageDigest
- Bouncy Castle: Extended cryptographic provider
- Spring Security Crypto: High-level encryption utilities
- Apache Commons Codec: Utility codecs and encoders
Framework-Specific Considerations:
- Spring Boot: Use Spring Security Crypto module for encryption
- JAX-RS: Secure API endpoints with proper cryptographic libraries
- Jakarta EE: Use the platform security APIs with strong algorithms
- Micronaut: Configure encryption for configuration properties
Primary Defence: Use AES-256 with GCM mode for symmetric encryption, RSA with 3072-bit keys for asymmetric encryption, and SHA-256 or SHA-3 for hashing. NIST rates 2048-bit RSA at 112-bit security and accepts it only through 2030; 3072-bit gives the 128-bit strength needed beyond that, and is what the secure patterns below use.
Common Vulnerable Patterns
Using DES Encryption
// VULNERABLE - DES in ECB mode: a 56-bit key, and identical plaintext blocks
// produce identical ciphertext blocks
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class WeakEncryptionService {
private static final String DES_ALGORITHM = "DES";
private SecretKey key;
public WeakEncryptionService() throws Exception {
// DES has only 56-bit effective key strength
KeyGenerator keyGen = KeyGenerator.getInstance(DES_ALGORITHM);
keyGen.init(56);
this.key = keyGen.generateKey();
}
public String encryptSSN(String ssn) throws Exception {
// Using ECB mode - also vulnerable
Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encrypted = cipher.doFinal(ssn.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
public String decryptSSN(String encryptedSSN) throws Exception {
Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedSSN));
return new String(decrypted);
}
}
// Spring Boot REST Controller using weak encryption
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final WeakEncryptionService encryptionService;
public UserController() throws Exception {
this.encryptionService = new WeakEncryptionService();
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody UserRequest request)
throws Exception {
String encryptedSSN = encryptionService.encryptSSN(request.getSsn());
// Store in database
return ResponseEntity.ok(new UserResponse("created", encryptedSSN));
}
}
Why this is vulnerable:
- DES has only 56-bit effective key strength, making brute-force feasible.
- ECB mode reveals duplicate blocks in the ciphertext, so an attacker can infer the structure of the plaintext without breaking the cipher.
Weak RSA Key Size
// VULNERABLE - Weak RSA Key Size
import java.security.*;
import javax.crypto.Cipher;
import java.util.Base64;
public class WeakRSAEncryption {
private KeyPair keyPair;
public WeakRSAEncryption() throws NoSuchAlgorithmException {
// 1024-bit RSA is considered weak
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(1024); // Insufficient key size
this.keyPair = keyGen.generateKeyPair();
}
public String encryptAPIKey(String apiKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, keyPair.getPublic());
byte[] encrypted = cipher.doFinal(apiKey.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
public String decryptAPIKey(String encryptedKey) throws Exception {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedKey));
return new String(decrypted);
}
}
// JAX-RS endpoint using weak RSA
Why this is vulnerable:
- 1024-bit RSA can be factored by well-resourced attackers.
- NIST deprecated 1024-bit keys in 2013.
- 2048-bit keys are rated at 112-bit security and accepted by NIST only through 2030; use 3072-bit for anything with a longer lifetime. SP 800-57 Part 1 Table 4 scopes this to applying protection - decrypting or verifying data already protected at 112-bit stays permitted as legacy use.
Using MD5 for Password Hashing
// VULNERABLE - Using MD5 for Password Hashing
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public class WeakPasswordService {
public String hashPassword(String password) throws NoSuchAlgorithmException {
// MD5 is cryptographically broken
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(password.getBytes());
return HexFormat.of().formatHex(hash);
}
public boolean verifyPassword(String password, String hash)
throws NoSuchAlgorithmException {
String computedHash = hashPassword(password);
return computedHash.equals(hash);
}
}
// Spring Boot authentication service
import org.springframework.stereotype.Service;
import org.springframework.security.core.userdetails.*;
@Service
public class UserAuthenticationService implements UserDetailsService {
private final WeakPasswordService passwordService;
private final UserRepository userRepository;
public UserAuthenticationService(UserRepository userRepository)
throws NoSuchAlgorithmException {
this.passwordService = new WeakPasswordService();
this.userRepository = userRepository;
}
public void registerUser(String username, String password) throws Exception {
String hashedPassword = passwordService.hashPassword(password);
User user = new User();
user.setUsername(username);
user.setPasswordHash(hashedPassword);
userRepository.save(user);
}
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPasswordHash())
.authorities("USER")
.build();
}
}
Why this is vulnerable:
- MD5 has practical collision attacks and is cryptographically broken.
- GPUs brute-force MD5 at billions of hashes per second, so a stolen database is cracked rather than merely leaked.
- There is no salt, so identical passwords produce identical hashes and a precomputed table reverses the common ones instantly.
- Speed is the problem, not just the algorithm: a password hash needs a deliberately slow function, which no general-purpose digest is.
Using SHA-1 for Digital Signatures
// VULNERABLE - SHA1withRSA for signing
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
public class LicenceSigner {
public byte[] sign(byte[] licence, PrivateKey key) throws Exception {
// VULNERABLE - SHA-1 is not collision resistant
Signature signature = Signature.getInstance("SHA1withRSA");
signature.initSign(key);
signature.update(licence);
return signature.sign();
}
public boolean verify(byte[] licence, byte[] sig, PublicKey key) throws Exception {
Signature signature = Signature.getInstance("SHA1withRSA");
signature.initVerify(key);
signature.update(licence);
return signature.verify(sig);
}
}
// Attack: a signature commits to the digest, not the document. Given a
// practical collision, an attacker prepares two files with the same SHA-1
// digest, gets the benign one signed, and attaches that signature to the
// other - it verifies, because the digest matches.
Why this is vulnerable:
- SHA-1 collisions are practical, demonstrated publicly since 2017 and cheaper every year.
- A signature is computed over the digest, so two documents sharing a digest share a valid signature - the attacker never needs the private key.
- This matters wherever a signature authorises something: licences, software updates, tokens, signed configuration.
- Signing is the case where collision resistance is load-bearing, which is why a SHA-1 finding on a signature path is more urgent than one on, say, a non-security checksum.
AES in ECB Mode
import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class WeakAESEncryption {
private SecretKey key;
public WeakAESEncryption() {
// The key size is not the problem here - the mode is
byte[] keyBytes = new byte[16]; // AES-128
new SecureRandom().nextBytes(keyBytes);
this.key = new SecretKeySpec(keyBytes, "AES");
}
public String encryptToken(String token) throws Exception {
// ECB mode is insecure - reveals patterns
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encrypted = cipher.doFinal(token.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
public String decryptToken(String encryptedToken) throws Exception {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedToken));
return new String(decrypted);
}
}
// Micronaut controller using weak AES
import io.micronaut.http.annotation.*;
import io.micronaut.http.HttpResponse;
@Controller("/api/tokens")
public class TokenController {
private final WeakAESEncryption encryption = new WeakAESEncryption();
@Post
public HttpResponse<TokenResponse> encryptToken(@Body TokenRequest request)
throws Exception {
String encrypted = encryption.encryptToken(request.getToken());
return HttpResponse.ok(new TokenResponse(encrypted));
}
}
Why this is vulnerable:
- ECB mode makes identical plaintext blocks produce identical ciphertext blocks.
- Pattern leakage exposes repeated values and structured content.
- There is no authentication, so a modified ciphertext decrypts without complaint.
- The AES-128 key is not what makes this example weak. AES-128 carries a full 128-bit security strength, and swapping in a 256-bit key leaves the ECB weakness exactly where it was. Change the mode, not the key size. Prefer AES-256 for new work as a margin decision, and do not report an existing AES-128 deployment as a CWE-326 finding on the key length alone.
SHA-1 for HMAC
// VULNERABLE - SHA-1 for HMAC
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public class WeakHMACService {
private static final String HMAC_ALGORITHM = "HmacSHA1"; // Deprecated
private SecretKeySpec secretKey;
public WeakHMACService(byte[] key) {
this.secretKey = new SecretKeySpec(key, HMAC_ALGORITHM);
}
public String generateSignature(String data)
throws NoSuchAlgorithmException, InvalidKeyException {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(secretKey);
byte[] signature = mac.doFinal(data.getBytes());
return HexFormat.of().formatHex(signature);
}
public boolean verifySignature(String data, String signature)
throws NoSuchAlgorithmException, InvalidKeyException {
String computed = generateSignature(data);
return MessageDigest.isEqual(
computed.getBytes(),
signature.getBytes()
);
}
}
// JAX-RS webhook endpoint with weak HMAC
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
@Path("/api/webhook")
public class WebhookResource {
private static final byte[] SECRET = "weak_secret_key".getBytes();
private final WeakHMACService hmacService = new WeakHMACService(SECRET);
@POST
@Consumes(MediaType.APPLICATION_JSON)
public Response processWebhook(
@HeaderParam("X-Signature") String signature,
String payload) throws Exception {
if (!hmacService.verifySignature(payload, signature)) {
return Response.status(Response.Status.FORBIDDEN)
.entity("Invalid signature")
.build();
}
// Process webhook
return Response.ok().entity("Processed").build();
}
}
Why this is vulnerable:
- The 2017 collision attacks are not what makes this a finding. HMAC's security rests on the hash behaving as a pseudorandom function rather than on collision resistance, so HMAC-SHA1 is not broken by them - which is why a SHA-1 finding here is less urgent than one on the signature path above.
- What does make it a finding is policy: NIST and PCI DSS disallow SHA-1 for new work, so it fails an audit whatever its cryptanalytic state.
HmacSHA256is the replacement. The tag goes from 160 to 256 bits, so whoever verifies the signature has to change at the same time.
Low Iteration PBKDF2
// VULNERABLE - Low Iteration PBKDF2
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.Base64;
public class WeakPBKDF2Service {
private static final int ITERATIONS = 1000; // Too low!
private static final int KEY_LENGTH = 128; // bits - fine on its own
private static final String FIXED_SALT = "fixed_salt_value"; // Fixed salt!
public String deriveKey(String password)
throws NoSuchAlgorithmException, InvalidKeySpecException {
PBEKeySpec spec = new PBEKeySpec(
password.toCharArray(),
FIXED_SALT.getBytes(),
ITERATIONS,
KEY_LENGTH
);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
byte[] hash = factory.generateSecret(spec).getEncoded();
return Base64.getEncoder().encodeToString(hash);
}
}
// Spring Boot configuration encryption
import org.springframework.stereotype.Service;
@Service
public class ConfigEncryptionService {
private final WeakPBKDF2Service pbkdf2Service = new WeakPBKDF2Service();
public String encryptConfig(String password, String configValue) throws Exception {
String key = pbkdf2Service.deriveKey(password);
// Use derived key for encryption
return encryptWithKey(key, configValue);
}
private String encryptWithKey(String key, String value) {
// Implementation
return value;
}
}
Why this is vulnerable:
- 1,000 iterations are far below OWASP's 600,000+ PBKDF2-SHA256 guidance.
- Fixed salts make identical passwords produce identical hashes.
- SHA-1 provides weaker security margins than SHA-256/SHA-512.
3DES Instead of AES
// VULNERABLE - 3DES Instead of AES
import javax.crypto.*;
import javax.crypto.spec.DESedeKeySpec;
import javax.crypto.spec.IvParameterSpec;
import java.security.spec.KeySpec;
import java.util.Base64;
public class Legacy3DESEncryption {
private SecretKey key;
public Legacy3DESEncryption(byte[] keyBytes) throws Exception {
// 3DES is deprecated
KeySpec keySpec = new DESedeKeySpec(keyBytes);
SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("DESede");
this.key = keyFactory.generateSecret(keySpec);
}
public String encryptCreditCard(String creditCard) throws Exception {
Cipher cipher = Cipher.getInstance("DESede/CBC/PKCS5Padding");
byte[] iv = new byte[8]; // 64-bit IV for 3DES
cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
byte[] encrypted = cipher.doFinal(creditCard.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
}
// Jakarta EE REST service
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/payments")
@Produces(MediaType.APPLICATION_JSON)
public class PaymentResource {
private Legacy3DESEncryption encryption;
public PaymentResource() throws Exception {
byte[] key = "FIXED_24_BYTE_KEY_123456".getBytes();
this.encryption = new Legacy3DESEncryption(key);
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
public Response processPayment(PaymentRequest request) throws Exception {
String encryptedCC = encryption.encryptCreditCard(request.getCreditCard());
// Process payment
return Response.ok()
.entity(new PaymentResponse("processed", encryptedCC))
.build();
}
}
Why this is vulnerable:
- 3DES deprecated by NIST (retired in 2023)
- 64-bit block size vulnerable to birthday attacks
- Slower than AES with no security benefit
Weak TLS Cipher Suite Configuration
// VULNERABLE - Weak TLS Cipher Suite Configuration
import javax.net.ssl.*;
import java.security.KeyStore;
public class WeakSSLConfiguration {
public SSLContext createWeakSSLContext() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, null, null);
// Default configuration may include weak ciphers
return context;
}
public HttpsURLConnection createConnection(String url) throws Exception {
HttpsURLConnection connection =
(HttpsURLConnection) new URL(url).openConnection();
SSLContext sslContext = createWeakSSLContext();
connection.setSSLSocketFactory(sslContext.getSocketFactory());
// No cipher suite restrictions - may use RC4, 3DES, etc.
return connection;
}
}
// Spring Boot with weak SSL
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
@Configuration
public class WeakHttpClientConfig {
@Bean
public HttpClient httpClient() throws Exception {
WeakSSLConfiguration sslConfig = new WeakSSLConfiguration();
SSLConnectionSocketFactory socketFactory =
new SSLConnectionSocketFactory(sslConfig.createWeakSSLContext());
return HttpClients.custom()
.setSSLSocketFactory(socketFactory)
.build();
}
}
Why this is vulnerable:
- May negotiate weak cipher suites (RC4, 3DES, MD5)
- No explicit cipher suite allowlist
- Vulnerable to downgrade attacks
- May not enforce TLS 1.2+ minimum version
Secure Patterns
AES-256-GCM Encryption
// SECURE - AES-256-GCM: authenticated encryption with a fresh IV per message
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
public class SecureAESGCMEncryption {
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH = 128; // bits
private static final int GCM_IV_LENGTH = 12; // bytes (96 bits recommended)
private static final int AES_KEY_SIZE = 256; // bits
private SecretKey key;
private SecureRandom secureRandom;
public SecureAESGCMEncryption() throws Exception {
// Generate strong AES-256 key
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(AES_KEY_SIZE);
this.key = keyGenerator.generateKey();
this.secureRandom = new SecureRandom();
}
public String encrypt(String plaintext) throws Exception {
// Generate random IV for each encryption
byte[] iv = new byte[GCM_IV_LENGTH];
secureRandom.nextBytes(iv);
// Configure GCM parameters
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
// Initialize cipher
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec);
// Optional: Add authenticated associated data (AAD) for context binding
cipher.updateAAD("CONTEXT_V1".getBytes(StandardCharsets.UTF_8));
// Encrypt
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// Combine IV + ciphertext for storage
byte[] encrypted = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, encrypted, 0, iv.length);
System.arraycopy(ciphertext, 0, encrypted, iv.length, ciphertext.length);
return Base64.getEncoder().encodeToString(encrypted);
}
public String decrypt(String encryptedData) throws Exception {
byte[] encrypted = Base64.getDecoder().decode(encryptedData);
// Extract IV and ciphertext
byte[] iv = new byte[GCM_IV_LENGTH];
byte[] ciphertext = new byte[encrypted.length - GCM_IV_LENGTH];
System.arraycopy(encrypted, 0, iv, 0, iv.length);
System.arraycopy(encrypted, iv.length, ciphertext, 0, ciphertext.length);
// Configure GCM parameters
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
// Initialize cipher
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec);
// Add same AAD used during encryption
cipher.updateAAD("CONTEXT_V1".getBytes(StandardCharsets.UTF_8));
// Decrypt and verify authentication tag
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, StandardCharsets.UTF_8);
}
}
Why this works:
- AES-256 provides a large key space that makes brute-force infeasible.
- GCM delivers authenticated encryption (confidentiality + integrity).
- A random 96-bit IV per encryption prevents deterministic ciphertexts.
- The 128-bit tag detects tampering before decryption succeeds.
- AAD binds ciphertext to context to prevent substitution attacks.
RSA-3072 with OAEP Padding
import java.security.*;
import javax.crypto.Cipher;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import java.nio.charset.StandardCharsets;
import java.security.spec.MGF1ParameterSpec;
import java.util.Base64;
public class SecureRSAEncryption {
private static final int KEY_SIZE = 3072; // 3072-bit RSA
private static final String TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
private KeyPair keyPair;
public SecureRSAEncryption() throws NoSuchAlgorithmException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(KEY_SIZE, new SecureRandom());
this.keyPair = keyGen.generateKeyPair();
}
public String encrypt(String plaintext) throws Exception {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
// Configure OAEP with SHA-256
OAEPParameterSpec oaepParams = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT
);
cipher.init(Cipher.ENCRYPT_MODE, keyPair.getPublic(), oaepParams);
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
}
public String decrypt(String encryptedData) throws Exception {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
OAEPParameterSpec oaepParams = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT
);
cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate(), oaepParams);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedData));
return new String(decrypted, StandardCharsets.UTF_8);
}
}
Why this works:
- RSA-3072 provides ~128-bit security for long-term protection.
- OAEP randomizes padding, blocking PKCS#1 v1.5 padding oracles.
- SHA-256 for both OAEP and MGF1 avoids weak hash defaults.
- Explicit OAEP parameters prevent provider default regressions.
- Suitable for small secrets; use hybrid encryption for bulk data.
BCrypt for Password Hashing
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.UUID;
public class SecurePasswordService {
private final PasswordEncoder passwordEncoder;
// A real hash of a value nobody can log in as, generated once at startup.
// It must be a well-formed 60-character BCrypt hash at the same strength:
// BCryptPasswordEncoder.matches() rejects anything that fails its format
// check and returns immediately, without hashing.
private final String dummyHash;
public SecurePasswordService() {
// BCrypt with strength 12 (2^12 = 4096 rounds)
this.passwordEncoder = new BCryptPasswordEncoder(12);
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString());
}
public String hashPassword(String password) {
// BCrypt automatically generates per-password random salt
return passwordEncoder.encode(password);
}
public boolean verifyPassword(String password, String storedHash) {
// Timing-safe password comparison
return passwordEncoder.matches(password, storedHash);
}
// Prevent username enumeration when the user doesn't exist
public boolean authenticateUser(String username, String password, String storedHash) {
if (storedHash == null) {
// Same work as a real verification, so the response time does not
// distinguish "no such user" from "wrong password"
passwordEncoder.matches(password, dummyHash);
return false;
}
return passwordEncoder.matches(password, storedHash);
}
}
Why this works:
- BCrypt is intentionally slow: the work factor sets how many key-expansion rounds run, and raising it raises brute-force cost.
- It is not memory-hard, and the work factor does not change that. BCrypt's state is Blowfish's P-array and S-boxes - 18 plus 1024 32-bit words, about 4 KB - and that size is fixed whatever the strength, because the cost parameter multiplies rounds rather than memory. Memory-hard designs such as Argon2id and scrypt instead make the memory requirement itself a tunable cost, which is what constrains an attacker running many guesses in parallel.
- OWASP now treats BCrypt as a legacy option: "The bcrypt password hashing function should only be used for password storage in legacy systems where Argon2 and scrypt are not available." Prefer Argon2id for new work. BCrypt at strength 12 is still a defensible position for a system already using it - the migration has a cost of its own, since every stored hash can only be upgraded as users next log in.
- Cost factor 12 (4096 rounds) is adjustable as hardware improves.
- Per-password random salts prevent rainbow table attacks.
- Encoded output stores salt + hash together for safe storage.
- Constant-time
matches()prevents a timing leak on the comparison itself. - The dummy hash closes the other timing leak, the one on the unknown-user
branch, and it only closes it if it is a real hash.
matches()checks the stored value against\A\$2(a|y|b)?\$\d\d\$[./0-9A-Za-z]{53}first and returnsfalsewithout hashing when it does not match, so a placeholder string such as"$2a$12$dummyhashtopreventtiming"returns in about 0.2 ms against roughly 245 ms for a genuine one at strength 12 - measured on Spring Security 6.5.5, JDK 26. That is the enumeration oracle the branch exists to remove, made a thousand times louder than no dummy check at all.
PBKDF2 with High Iteration Count
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class SecurePBKDF2Service {
private static final int ITERATIONS = 600_000; // OWASP recommended minimum for PBKDF2-HMAC-SHA256
private static final int KEY_LENGTH = 256; // bits
private static final int SALT_LENGTH = 32; // bytes
private static final String ALGORITHM = "PBKDF2WithHmacSHA256";
private final SecureRandom secureRandom = new SecureRandom();
public String hashPassword(String password) throws Exception {
// Generate random salt
byte[] salt = new byte[SALT_LENGTH];
secureRandom.nextBytes(salt);
// Derive key
byte[] hash = deriveKey(password, salt);
// Return hash:salt format for storage
return Base64.getEncoder().encodeToString(hash) + ":" +
Base64.getEncoder().encodeToString(salt);
}
public boolean verifyPassword(String password, String storedHash) throws Exception {
// Extract hash and salt
String[] parts = storedHash.split(":");
byte[] hash = Base64.getDecoder().decode(parts[0]);
byte[] salt = Base64.getDecoder().decode(parts[1]);
// Recompute hash
byte[] computedHash = deriveKey(password, salt);
// Timing-safe comparison
return MessageDigest.isEqual(computedHash, hash);
}
private byte[] deriveKey(String password, byte[] salt) throws Exception {
PBEKeySpec spec = new PBEKeySpec(
password.toCharArray(),
salt,
ITERATIONS,
KEY_LENGTH
);
SecretKeyFactory factory = SecretKeyFactory.getInstance(ALGORITHM);
return factory.generateSecret(spec).getEncoded();
}
}
Why this works:
- 600,000 iterations matches the current OWASP recommendation for PBKDF2-HMAC-SHA256 and raises brute-force cost.
- Per-user 32-byte random salts prevent rainbow table attacks.
- SHA-256 avoids deprecated SHA-1 and provides strong hash security.
MessageDigest.isEqual()prevents timing leaks in comparisons.- The encoded
hash:saltformat keeps all components together.
SHA-256 Digital Signatures
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.ECGenParameterSpec;
public class LicenceSigner {
// SECURE - SHA-256 with a 3072-bit RSA key
public static KeyPair generateRsaKeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(3072);
return generator.generateKeyPair();
}
// SECURE - or ECDSA on P-256, which gives comparable strength with a
// much smaller key and faster signing
public static KeyPair generateEcKeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(new ECGenParameterSpec("secp256r1"));
return generator.generateKeyPair();
}
public byte[] sign(byte[] licence, PrivateKey key) throws Exception {
// "SHA256withRSA" for an RSA key, "SHA256withECDSA" for an EC key
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(key);
signature.update(licence);
return signature.sign();
}
public boolean verify(byte[] licence, byte[] sig, PublicKey key) throws Exception {
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initVerify(key);
signature.update(licence);
// verify() returns false for a bad signature and throws for a
// malformed one - treat both as failure, and never ignore the result
return signature.verify(sig);
}
}
Why this works: The signature is computed over a SHA-256 digest, for which
no collision is known, so an attacker cannot construct a second document that
shares a digest with a signed one. The algorithm name is fixed in code rather
than read from the document being verified, which is what stops an attacker
downgrading the check by claiming a weaker algorithm. SHA256withECDSA with
P-256 is an equally good choice and produces far smaller signatures; prefer
RSA only where an existing verifier requires it. For new RSA keys use 3072
bits - NIST rates 2048-bit at 112-bit strength and accepts it for applying
protection only through 2030.
RSA-PSS: For new designs,
SHA256withRSA/PSSis the better padding scheme and is available through the sameSignatureAPI. PlainSHA256withRSAuses PKCS#1 v1.5 padding, which is still acceptable for signatures - the Bleichenbacher attacks concern v1.5 encryption, not signing - but PSS has a security proof that v1.5 lacks.
HMAC-SHA256 for Message Authentication
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.HexFormat;
public class SecureHMACService {
private static final String ALGORITHM = "HmacSHA256";
private static final int KEY_SIZE = 32; // 256 bits
private final SecretKeySpec secretKey;
public SecureHMACService(byte[] key) {
if (key.length < KEY_SIZE) {
throw new IllegalArgumentException("Key must be at least 256 bits");
}
this.secretKey = new SecretKeySpec(key, ALGORITHM);
}
public static SecureHMACService withRandomKey() {
byte[] key = new byte[KEY_SIZE];
new SecureRandom().nextBytes(key);
return new SecureHMACService(key);
}
public String generateSignature(String data) throws Exception {
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(secretKey);
byte[] signature = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(signature);
}
public boolean verifySignature(String data, String signature) throws Exception {
String expected = generateSignature(data);
// Timing-safe comparison
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8)
);
}
}
Why this works:
- HMAC-SHA256 provides integrity and authenticity with modern security strength.
- A 256-bit key matches SHA-256's security level.
- HMAC construction prevents length-extension attacks.
withRandomKey()generates a fresh 256-bit secret usingSecureRandom.- Constant-time comparison reduces timing side channels.
Spring Security Crypto Module
import org.springframework.security.crypto.encrypt.BytesEncryptor;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.security.crypto.keygen.KeyGenerators;
public class SpringCryptoService {
private final BytesEncryptor encryptor;
public SpringCryptoService(String password, String hexEncodedSalt) {
// SECURE - Encryptors.stronger() is AES-256-GCM (authenticated encryption)
// Encryptors.standard() is AES-256-CBC and is NOT authenticated - avoid it
this.encryptor = Encryptors.stronger(password, hexEncodedSalt);
}
public byte[] encrypt(byte[] plaintext) {
return encryptor.encrypt(plaintext);
}
public byte[] decrypt(byte[] ciphertext) {
return encryptor.decrypt(ciphertext);
}
public static String generateSalt() {
// 8-byte SecureRandom key, hex-encoded as a String
return KeyGenerators.string().generateKey();
}
}
Why this works:
Encryptors.stronger()uses AES-256-GCM, so confidentiality and integrity come from a single authenticated mode rather than a hand-assembled Encrypt-then-MAC.- A 16-byte random IV is applied per message, so identical plaintexts do not produce identical ciphertexts.
- The key is PBKDF2-derived from the password and salt, avoiding a raw password used directly as key material.
- The factory API keeps mode, padding, and IV handling out of application code, where they are easy to get wrong.
Version note:
Encryptors.queryableText()was deprecated and removed in Spring Security 6.0; code still calling it will not compile against 6.x.Encryptors.delux()was not removed - it is present in 6.5.x and is theTextEncryptorwrapper aroundstronger(), so it is AES-256-GCM with hex-encoded output. Reach for it when you need a string rather than bytes:Encryptors.text()is the wrapper aroundstandard()(CBC, unauthenticated) and is the one to avoid.stronger()anddelux()share a key derivation and a ciphertext format, so bytes written by one are readable by the other once you hex-decode - verified againstspring-security-crypto6.5.5.
Considerations
Classify the use before choosing a replacement. "Use a modern algorithm" is
not a single answer, and picking the wrong primitive is the common failure. For
password storage use BCrypt, Argon2 or scrypt through
BCryptPasswordEncoder or Argon2PasswordEncoder - never a plain digest,
however modern, because speed is the property you are trying to remove. For
encryption use Cipher.getInstance("AES/GCM/NoPadding") with a 256-bit key
and a unique 12-byte IV per message. For integrity use
Mac.getInstance("HmacSHA256"). For signatures use SHA256withRSA with
3072-bit keys, or SHA256withECDSA. For key derivation from a password use
PBKDF2WithHmacSHA256 at a current iteration count.
The page's Overview says SHA-256 for hashing; that is not a password instruction. A general-purpose digest is the right tool for integrity and the wrong tool for passwords no matter which digest it is.
Migration Considerations
Changing an algorithm changes the data it produced. Before the fix ships, work out which stores are affected: stored password hashes, encrypted database columns, tokens already issued, and any persisted signatures. Each has a different answer.
Password hashes cannot be converted - you do not have the passwords. Verify against the old format, and rehash with the new one on the next successful login, retiring the old path when the population has drained.
Encrypted columns can be converted, but not instantly. Keep the old key and algorithm readable while a backfill decrypts and re-encrypts each row, and only remove the legacy path once the backfill has completed and been verified. Removing it first turns a migration into data loss.
Common Pitfalls
- Writing
Cipher.getInstance("AES")and assuming a safe default: The transformation string without a mode and padding resolves to ECB on most providers, which leaks structure across blocks. Always name the mode and padding explicitly. - Reusing a GCM IV across messages under the same key: GCM fails catastrophically here - it is not a gradual weakening. Two messages sharing an IV leak the XOR of their plaintexts and expose the authentication subkey, breaking integrity for every message under that key. The IV need not be secret, but it must be unique.
Testing
A scanner confirms the weak algorithm is gone. It cannot confirm the replacement works, and a key-strength change is unusually good at passing review while breaking data that already exists. Assert each of these:
- Ciphertext round-trips. Encrypt, then decrypt with a freshly obtained
Cipherinstance, and compare against the original plaintext. Reusing the instance hides the most common defect, where the provider generates an IV internally and it is never returned with the ciphertext. - Tampering is rejected. Flip one byte of the ciphertext, one byte of the
tag, and one byte of the IV, and confirm each throws
AEADBadTagExceptionrather than returning plaintext. - The key size actually applied. Assert
((RSAPublicKey) keyPair.getPublic()).getModulus().bitLength() == 3072rather than trusting theinitialize()argument, and assertsecretKey.getEncoded().lengthfor symmetric keys. - Data encrypted before the change still decrypts. Keep a fixture encrypted under the old algorithm and assert the dual-read path returns the original plaintext. This is the test that fails in production if it is missing.
- Old password hashes still verify, and are upgraded on use. Assert that a
correct password checked against a stored legacy hash succeeds, that the
stored hash is then rewritten in the new format, and that a wrong password
still fails at both stages. Spring Security's
DelegatingPasswordEncoderhandles the read side by prefix, but the rewrite on successful login is application code and needs its own test. - Password hashing is slow enough. Time one
matchescall and assert it falls in the intended range (roughly 250-500ms). A work factor lowered to speed up the test suite tends to reach production.
Additional Resources
- Bouncy Castle Java
- CWE-326: Inadequate Encryption Strength
- Java Cryptography Architecture (JCA)
- NIST Key Management Guidelines
- NIST SP 800-131A Rev 2: Transitioning the Use of Cryptographic Algorithms and Key Lengths - RSA-2048 provides 112-bit security strength, which is currently acceptable for applying protection
- NIST SP 800-57 Part 1 Rev 5: Recommendation for Key Management - Table 4 is the source for the 2030 date: 112-bit strength is Acceptable for applying protection (generating keys, encrypting, signing) through 2030 and Disallowed from 2031, while processing already-protected data stays permitted as legacy use
- OWASP Cryptographic Storage Cheat Sheet
- OWASP Password Storage Cheat Sheet - the source for the 600,000 PBKDF2-HMAC-SHA256 iteration count, the BCrypt work factor, and the position that BCrypt is for legacy systems where Argon2 and scrypt are unavailable
- RFC 9106: Argon2 - the memory-hard construction OWASP recommends first, and its parameter guidance
- Spring Security Crypto Module (javadoc)
- Spring Security Crypto Module (reference) - current
Encryptorsfactory methods and what each algorithm provides