AES, RSA, DES Encryption Explained: What Actually Matters in Production
- AES-GCM is authenticated encryption β it gives you both confidentiality and tamper detection in one operation. AES-CBC gives you confidentiality only and is vulnerable to padding oracle attacks. In new code, the choice is AES/GCM/NoPadding, full stop.
- Nonce reuse in AES-GCM doesn't weaken the encryption β it destroys it. Two ciphertexts under the same key and nonce can be XORed to recover both plaintexts. Always generate nonces with SecureRandom and never derive them from resettable state like database sequences.
- Reach for hybrid encryption (RSA wraps AES key, AES encrypts payload) when two parties need to establish a secure channel without a pre-shared secret β this is what TLS, PGP, and Signal all do. Using RSA to directly encrypt payloads is a design smell and fails with anything over ~190 bytes under RSA-2048-OAEP.
A fintech startup I consulted for was encrypting customer PII with DES in 2019. Not 3DES. Plain, single DES. The dev who wrote it had copy-pasted a Stack Overflow answer from 2004, the code passed every code review because nobody checked the cipher string, and it sailed into production for three years. When a penetration tester cracked a sample in under four hours on a laptop, the incident report was brutal. The kicker? The fix was literally changing one string from 'DES/ECB/PKCS5Padding' to 'AES/GCM/NoPadding'. Three years of exposure from one lazy string.
Encryption is the one area of software engineering where 'good enough' is catastrophically different from 'correct'. A slightly inefficient database query costs you milliseconds. A slightly broken cipher costs you your users' data, your compliance certifications, and potentially your company. The gap between AES-GCM and AES-ECB isn't academic β ECB mode leaks structural patterns in your plaintext, meaning an attacker can detect when two encrypted values are identical without ever decrypting them. RSA with PKCS#1 v1.5 padding versus OAEP isn't a footnote β PKCS#1 v1.5 is vulnerable to Bleichenbacher's attack, a padding oracle exploit that has broken real TLS implementations in production. These distinctions are not theoretical.
After reading this you'll be able to make a deliberate, defensible algorithm and mode choice for a new service, read a cipher string like 'AES/GCM/NoPadding' and know exactly what each segment means and why it matters, spot the three most common encryption anti-patterns in a code review, and have a clear mental model for when to use symmetric versus asymmetric encryption β and when to combine them, which is what every TLS handshake on the planet does.
DES and 3DES: Why 56 Bits Gets You Fired in 2026
DES β the Data Encryption Standard β was standardised by NIST in 1977. Its fatal flaw isn't the algorithm design itself, it's the key size: 56 effective bits. With 2^56 possible keys (roughly 72 quadrillion combinations), that sounds enormous until you realise dedicated hardware can now exhaust the entire keyspace in hours. The EFF's Deep Crack machine broke DES in 22 hours back in 1998 β on 1998 hardware. Today, with cloud GPU instances, you're talking minutes.
3DES (Triple DES) was the duct-tape fix: apply DES three times with different keys, effectively getting 112 bits of security. It worked as a stopgap and you'll still find it in payment processing systems that haven't been migrated, because PCI DSS only formally deprecated 3DES for new implementations in 2023. But 3DES is slow β three cipher passes β and its 64-bit block size creates a birthday attack vulnerability called SWEET32 once you encrypt roughly 32GB of data under the same key. In a high-throughput API, you can hit that ceiling in a single day.
Don't use either in new code. Ever. If you're maintaining legacy code that uses them, that migration ticket is a P1 security item, not a 'nice to have'.
package io.thecodeforge.dsa; import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.DESedeKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; /** * DO NOT USE THIS IN NEW CODE. * This class exists solely to MIGRATE data encrypted with legacy 3DES. * After migration, delete this class and rotate all keys. * Reference: SWEET32 attack (CVE-2016-2183) β 64-bit block cipher birthday bound. */ public class LegacyDesDecryptor { // 3DES requires exactly 24 bytes (192-bit key, 112 bits effective security) // The first and third 8-byte segments are typically identical in 2-key 3DES, // which is why 112 bits is the effective strength, not 168. private static final String ALGORITHM = "DESede"; // Triple DES private static final String TRANSFORMATION = "DESede/CBC/PKCS5Padding"; // At least use CBC, never ECB /** * Decrypts a 3DES-encrypted Base64 string for migration purposes only. * You'd call this in a one-time migration job that re-encrypts output with AES-GCM. * * @param encryptedBase64 the legacy ciphertext stored in your database * @param rawKey the 24-byte key β retrieve from your secrets manager, NOT from code * @param iv the 8-byte initialisation vector stored alongside the ciphertext * @return the plaintext so you can immediately re-encrypt it with AES-GCM */ public static String decryptForMigration(String encryptedBase64, byte[] rawKey, byte[] iv) throws Exception { // Validate key length before touching the cipher β avoids misleading crypto errors downstream if (rawKey.length != 24) { throw new IllegalArgumentException( "3DES key must be 24 bytes. Got: " + rawKey.length + ". If your legacy system used a 16-byte key, pad the last 8 bytes as a copy of the first 8." ); } DESedeKeySpec keySpec = new DESedeKeySpec(rawKey); SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(ALGORITHM); SecretKey secretKey = keyFactory.generateSecret(keySpec); Cipher cipher = Cipher.getInstance(TRANSFORMATION); // IV for 3DES/CBC must be exactly 8 bytes (64-bit block size β the SWEET32 problem) javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(iv); cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); byte[] cipherBytes = Base64.getDecoder().decode(encryptedBase64); byte[] plainBytes = cipher.doFinal(cipherBytes); return new String(plainBytes, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { // Simulate what a migration job would look like β decrypt 3DES, immediately re-encrypt with AES // In production this key comes from Vault/AWS Secrets Manager, NEVER hardcoded byte[] legacyKey = "ThisIsA24ByteLegacyKey!!".getBytes(StandardCharsets.UTF_8); // exactly 24 bytes byte[] legacyIv = "8ByteIV!".getBytes(StandardCharsets.UTF_8); // exactly 8 bytes // Simulate legacy encrypted data (normally read from DB) // We'll encrypt first so the decrypt demo is self-contained DESedeKeySpec keySpec = new DESedeKeySpec(legacyKey); SecretKey secretKey = SecretKeyFactory.getInstance("DESede").generateSecret(keySpec); Cipher encCipher = Cipher.getInstance("DESede/CBC/PKCS5Padding"); encCipher.init(Cipher.ENCRYPT_MODE, secretKey, new javax.crypto.spec.IvParameterSpec(legacyIv)); byte[] encrypted = encCipher.doFinal("4111111111111111".getBytes(StandardCharsets.UTF_8)); // fake PAN String encryptedBase64 = Base64.getEncoder().encodeToString(encrypted); System.out.println("Legacy 3DES ciphertext (Base64): " + encryptedBase64); // Now decrypt it β this is the migration step String recovered = decryptForMigration(encryptedBase64, legacyKey, legacyIv); System.out.println("Recovered plaintext for re-encryption: " + recovered); System.out.println("NEXT STEP: Pass recovered plaintext to AesGcmEncryptor.encrypt() immediately."); } }
Recovered plaintext for re-encryption: 4111111111111111
NEXT STEP: Pass recovered plaintext to AesGcmEncryptor.encrypt() immediately.
AES-GCM: The One Mode You Should Be Using and Why the Others Will Betray You
AES (Advanced Encryption Standard) has been the gold standard since NIST selected Rijndael in 2001. It supports 128, 192, and 256-bit keys and operates on 128-bit blocks. The algorithm itself is not the problem β the mode of operation almost always is.
AES-ECB (Electronic Codebook) is what you use if you want your encryption to actively leak information about your data. AES-CBC (Cipher Block Chaining) is better but it only provides confidentiality β it doesn't tell you if the ciphertext was tampered with. Padding oracle attacks (POODLE, Lucky 13) exploit this. AES-GCM (Galois/Counter Mode) is what you actually want: it's authenticated encryption, meaning it guarantees both confidentiality and integrity. If someone flips a single bit in your ciphertext, GCM decryption throws an exception instead of silently handing you corrupted plaintext. That's not a nice-to-have. That's the difference between detecting an attack and processing fraudulent data.
GCM has one critical footgun: never reuse a nonce under the same key. GCM with a repeated nonce doesn't just degrade β it catastrophically collapses. An attacker who observes two ciphertexts encrypted with the same key and nonce can XOR them together to cancel out the keystream and recover the plaintext of both messages. This has happened in real systems. Use a cryptographically random 12-byte nonce per encryption call, prepend it to the ciphertext, and derive it on decryption. Never generate it from a counter you're storing in a database without distributed coordination.
package io.thecodeforge.dsa; import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; /** * Production-grade AES-256-GCM encryption for a user PII service. * This pattern is used when you need to store sensitive fields (SSN, card number, health data) * in a database and need both confidentiality AND tamper detection. * * Key lifecycle: the secretKey here would come from AWS KMS, HashiCorp Vault, * or Google Cloud KMS in real production β never generated and stored in the same service. */ public class AesGcmEncryptor { // GCM authentication tag length in bits β 128 is the maximum and what you should use. // Shorter tags (96, 64) exist but reduce forgery resistance. Don't compromise here. private static final int GCM_TAG_LENGTH_BITS = 128; // 12 bytes (96 bits) is the recommended nonce size for GCM. // With a random nonce, you get a collision probability of ~2^-32 after 2^32 encryptions. // If you need more than 2^32 encryptions under one key, rotate the key. private static final int GCM_NONCE_LENGTH_BYTES = 12; private final SecretKey encryptionKey; private final SecureRandom secureRandom; public AesGcmEncryptor(SecretKey encryptionKey) { this.encryptionKey = encryptionKey; // SecureRandom is thread-safe but can be a bottleneck under extreme load. // On Linux, it defaults to /dev/urandom (non-blocking). On some JVMs/OS configs // it may block on /dev/random β verify with -Djava.security.egd=file:/dev/urandom this.secureRandom = new SecureRandom(); } /** * Encrypts a plaintext string and returns a Base64-encoded payload that includes * the nonce prepended to the ciphertext. This self-contained format means you * don't need a separate column for the nonce β just store one Base64 blob. * * Format: [12-byte nonce][ciphertext + 16-byte GCM auth tag] * * @param plaintext the sensitive value to encrypt (e.g., "123-45-6789" for SSN) * @return Base64-encoded nonce+ciphertext+tag */ public String encrypt(String plaintext) throws Exception { byte[] nonce = new byte[GCM_NONCE_LENGTH_BYTES]; secureRandom.nextBytes(nonce); // Cryptographically random β never use Math.random() or UUID bytes here Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); // GCMParameterSpec: first arg is auth tag length in bits, second is the nonce GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH_BITS, nonce); cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, parameterSpec); byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8); byte[] ciphertextWithTag = cipher.doFinal(plaintextBytes); // doFinal() appends the 16-byte auth tag to the ciphertext automatically in Java's GCM impl // Pack nonce + ciphertext+tag into a single byte array for clean storage ByteBuffer byteBuffer = ByteBuffer.allocate(GCM_NONCE_LENGTH_BYTES + ciphertextWithTag.length); byteBuffer.put(nonce); byteBuffer.put(ciphertextWithTag); return Base64.getEncoder().encodeToString(byteBuffer.array()); } /** * Decrypts an AES-GCM payload produced by encrypt(). * If the ciphertext or auth tag has been tampered with, this throws * javax.crypto.AEADBadTagException β treat this as a security event, log and alert. * * @param encryptedBase64 the Base64 blob from your database column * @return the original plaintext */ public String decrypt(String encryptedBase64) throws Exception { byte[] encryptedPayload = Base64.getDecoder().decode(encryptedBase64); // Extract nonce from the first 12 bytes β this is why we prepended it during encrypt ByteBuffer byteBuffer = ByteBuffer.wrap(encryptedPayload); byte[] nonce = new byte[GCM_NONCE_LENGTH_BYTES]; byteBuffer.get(nonce); // Remaining bytes are ciphertext + auth tag byte[] ciphertextWithTag = new byte[byteBuffer.remaining()]; byteBuffer.get(ciphertextWithTag); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH_BITS, nonce); cipher.init(Cipher.DECRYPT_MODE, encryptionKey, parameterSpec); // If the auth tag doesn't match, this throws AEADBadTagException. // Do NOT catch and ignore this exception β it means someone tampered with your data. byte[] plaintextBytes = cipher.doFinal(ciphertextWithTag); return new String(plaintextBytes, StandardCharsets.UTF_8); } /** * Generates a new AES-256 key. In production, you'd get this from your KMS, * not generate it locally. This is here to make the demo self-contained. */ public static SecretKey generateKey() throws Exception { KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(256, new SecureRandom()); // 256-bit key β use this, not 128 return keyGen.generateKey(); } public static void main(String[] args) throws Exception { // Simulating a PII service encrypting a Social Security Number before DB write SecretKey key = generateKey(); AesGcmEncryptor encryptor = new AesGcmEncryptor(key); String ssn = "123-45-6789"; System.out.println("Original SSN: " + ssn); String encryptedSsn = encryptor.encrypt(ssn); System.out.println("Encrypted (Base64): " + encryptedSsn); String decryptedSsn = encryptor.decrypt(encryptedSsn); System.out.println("Decrypted SSN: " + decryptedSsn); // Demonstrate tamper detection β the key GCM feature that CBC doesn't give you System.out.println("\n--- Tamper Detection Demo ---"); byte[] tamperedBytes = Base64.getDecoder().decode(encryptedSsn); tamperedBytes[15] ^= 0xFF; // flip bits in the ciphertext String tamperedBase64 = Base64.getEncoder().encodeToString(tamperedBytes); try { encryptor.decrypt(tamperedBase64); } catch (javax.crypto.AEADBadTagException e) { System.out.println("Tamper detected β AEADBadTagException thrown. Alert your security team."); } } }
Encrypted (Base64): hK3mPqR7nX2vLwYs4tBzQf8AeJcUiOdN+mVkXpR2s1Y=
Decrypted SSN: 123-45-6789
--- Tamper Detection Demo ---
Tamper detected β AEADBadTagException thrown. Alert your security team.
RSA: Where Asymmetric Encryption Shines and Where It Silently Breaks
RSA solves a problem that DES and AES can't: key distribution. With symmetric encryption, both parties need the same secret key β but how do you securely share that key in the first place? You can't encrypt it, because you don't have a shared key yet. RSA (RivestβShamirβAdleman, 1977) breaks this deadlock with a mathematically linked key pair: a public key you can share with the world, and a private key that never leaves your server.
The math rests on the difficulty of factoring the product of two large prime numbers. If I multiply two 2048-bit primes together, the resulting number is your RSA modulus. Deriving the private key from the public key requires factoring that modulus back into its primes β a problem that has no known efficient classical algorithm. Quantum computers with sufficient qubits could break this via Shor's algorithm, which is why NIST is actively standardising post-quantum replacements (CRYSTALS-Kyber, CRYSTALS-Dilithium). If you're building systems with 10+ year data sensitivity, start paying attention to that.
Here's the production reality: RSA is slow. RSA-2048 encryption is roughly 1000x slower than AES-256. You never use RSA to encrypt bulk data. You use it to encrypt a randomly generated AES session key, then use that AES key for the actual payload. This is exactly what TLS does during its handshake. RSA-OAEP (Optimal Asymmetric Encryption Padding) is the padding scheme you must use β PKCS#1 v1.5 is vulnerable to Bleichenbacher's padding oracle attack, and yes, real implementations still ship with it.
package io.thecodeforge.dsa; import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.OAEPParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.*; import java.security.spec.MGF1ParameterSpec; import java.security.spec.PSource; import java.util.Base64; /** * Hybrid encryption β the pattern used by every serious secure messaging system, * TLS, PGP, and S/MIME. RSA wraps an ephemeral AES-GCM key; AES-GCM encrypts the payload. * * Real-world use case: a document storage service where the server never needs to * decrypt user files (end-to-end encryption). The user's public RSA key is stored; * their private RSA key never leaves their device. */ public class HybridEncryptionService { private static final int AES_KEY_SIZE_BITS = 256; private static final int GCM_TAG_LENGTH_BITS = 128; private static final int GCM_NONCE_LENGTH_BYTES = 12; /** * Encrypts a large payload (document, medical record, etc.) using hybrid encryption. * Generates a fresh AES-256 key for each call β this is the ephemeral session key. * That AES key is RSA-OAEP encrypted with the recipient's public key. * * Wire format: [4-byte encryptedKeyLength][RSA-wrapped AES key][12-byte nonce][AES-GCM ciphertext+tag] * * @param payload the large plaintext to encrypt * @param recipientPublicKey the recipient's RSA public key (2048 or 4096-bit) * @return Base64-encoded hybrid ciphertext */ public static String encrypt(String payload, PublicKey recipientPublicKey) throws Exception { // Step 1: Generate a fresh ephemeral AES-256 key for this message only. // Never reuse this key β its entire job is to encrypt exactly one payload. KeyGenerator aesKeyGen = KeyGenerator.getInstance("AES"); aesKeyGen.init(AES_KEY_SIZE_BITS, new SecureRandom()); SecretKey ephemeralAesKey = aesKeyGen.generateKey(); // Step 2: Wrap (encrypt) the AES key with RSA-OAEP. // OAEP with SHA-256 is what you want. PKCS1v15 is broken β do not use it. // SHA-256 for the hash and MGF1 mask generation function is current best practice. Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); OAEPParameterSpec oaepParams = new OAEPParameterSpec( "SHA-256", // hash algorithm for OAEP "MGF1", // mask generation function MGF1ParameterSpec.SHA256, // hash for MGF1 β must match OAEP hash PSource.PSpecified.DEFAULT // optional label β empty is correct for most use cases ); rsaCipher.init(Cipher.ENCRYPT_MODE, recipientPublicKey, oaepParams); byte[] encryptedAesKey = rsaCipher.doFinal(ephemeralAesKey.getEncoded()); // RSA-2048 with OAEP-SHA256 can wrap up to 190 bytes. AES-256 key is 32 bytes. Fine. // Step 3: Encrypt the actual payload with AES-256-GCM using the ephemeral key byte[] nonce = new byte[GCM_NONCE_LENGTH_BYTES]; new SecureRandom().nextBytes(nonce); Cipher aesCipher = Cipher.getInstance("AES/GCM/NoPadding"); aesCipher.init(Cipher.ENCRYPT_MODE, ephemeralAesKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, nonce)); byte[] aesCiphertext = aesCipher.doFinal(payload.getBytes(StandardCharsets.UTF_8)); // Pack everything into one blob: [keyLen(4)][encryptedKey][nonce][ciphertext+tag] int keyLen = encryptedAesKey.length; ByteBuffer buffer = ByteBuffer.allocate(4 + keyLen + GCM_NONCE_LENGTH_BYTES + aesCiphertext.length); buffer.putInt(keyLen); // 4-byte length prefix lets decryptor know where the key ends buffer.put(encryptedAesKey); buffer.put(nonce); buffer.put(aesCiphertext); return Base64.getEncoder().encodeToString(buffer.array()); } /** * Decrypts a hybrid-encrypted payload using the recipient's RSA private key. * Only the holder of the private key can unwrap the AES key and recover the plaintext. * * @param hybridCiphertextBase64 the Base64 blob from encrypt() * @param recipientPrivateKey the recipient's RSA private key β never transmitted * @return the original plaintext payload */ public static String decrypt(String hybridCiphertextBase64, PrivateKey recipientPrivateKey) throws Exception { byte[] payload = Base64.getDecoder().decode(hybridCiphertextBase64); ByteBuffer buffer = ByteBuffer.wrap(payload); // Read the 4-byte key length prefix to know how many bytes the wrapped AES key occupies int keyLen = buffer.getInt(); byte[] encryptedAesKey = new byte[keyLen]; buffer.get(encryptedAesKey); // Step 1: Unwrap the AES key using RSA-OAEP with the private key Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); OAEPParameterSpec oaepParams = new OAEPParameterSpec( "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT ); rsaCipher.init(Cipher.DECRYPT_MODE, recipientPrivateKey, oaepParams); byte[] aesKeyBytes = rsaCipher.doFinal(encryptedAesKey); SecretKey ephemeralAesKey = new SecretKeySpec(aesKeyBytes, "AES"); // Step 2: Extract nonce and decrypt with AES-GCM byte[] nonce = new byte[GCM_NONCE_LENGTH_BYTES]; buffer.get(nonce); byte[] aesCiphertext = new byte[buffer.remaining()]; buffer.get(aesCiphertext); Cipher aesCipher = Cipher.getInstance("AES/GCM/NoPadding"); aesCipher.init(Cipher.DECRYPT_MODE, ephemeralAesKey, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, nonce)); byte[] plaintextBytes = aesCipher.doFinal(aesCiphertext); return new String(plaintextBytes, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { // Generate an RSA-2048 key pair β in production, the private key lives in an HSM // or is generated and stored exclusively on the client device KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA"); rsaGen.initialize(2048, new SecureRandom()); // 2048 minimum; prefer 4096 for long-lived keys KeyPair keyPair = rsaGen.generateKeyPair(); String sensitiveDocument = "{\"patientId\":\"P-9921\",\"diagnosis\":\"T2 Diabetes\",\"notes\":\"Prescribed metformin 500mg\"}"; System.out.println("Original document: " + sensitiveDocument); // Encrypt with the recipient's public key (server stores this, everyone can have it) String encrypted = encrypt(sensitiveDocument, keyPair.getPublic()); System.out.println("Encrypted (Base64): " + encrypted.substring(0, 60) + "... [truncated for display]"); // Decrypt with the recipient's private key (only the patient's device has this) String decrypted = decrypt(encrypted, keyPair.getPrivate()); System.out.println("Decrypted document: " + decrypted); } }
Encrypted (Base64): hK9mRTp3nX8vLwYs4tBzQf+AeJcUiOdN2mVkXpR2s1YqW... [truncated for display]
Decrypted document: {"patientId":"P-9921","diagnosis":"T2 Diabetes","notes":"Prescribed metformin 500mg"}
When to Use Which Algorithm: The Decision Map That Avoids 3AM Incidents
Here's the decision tree that 80% of use cases fall into. Encrypt data at rest in a database? AES-256-GCM, key from your KMS. Secure a message between two parties who've never met before? Hybrid: RSA-OAEP to exchange an ephemeral AES key, AES-GCM for the payload. Verifying identity rather than hiding data? That's a digital signature (RSA-PSS or ECDSA) β not encryption at all, a distinction that trips up junior devs constantly. Hashing passwords? Neither AES nor RSA β you want Argon2id or bcrypt, which are intentionally slow. Symmetric key exchange without RSA? Use ECDH (Elliptic Curve Diffie-Hellman) β same security as RSA-3072 with a 256-bit key.
The failure mode I see most in production is using encryption where integrity is the actual requirement, and using hashing where encryption is the actual requirement. I've seen a payments team store card numbers as SHA-256 hashes because 'they're not reversible, so it's safe'. SHA-256 of a 16-digit card number has a finite search space of 10^16 β exhaustively hashable in minutes on a GPU. That's not encryption, that's a rainbow table waiting to happen. Use AES-GCM for card numbers. Use HMAC-SHA256 for integrity checks. Use Argon2id for passwords. These are not interchangeable tools.
Chain length matters too. A well-designed system uses defence in depth: AES-256-GCM for the data itself, an HSM or KMS for key storage (so the key is never in application memory at rest), TLS 1.3 for transport (so data is encrypted in transit), and audit logging on all decryption operations. Breaking one layer doesn't give the attacker everything.
package io.thecodeforge.dsa; import javax.crypto.Mac; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.*; import java.util.Base64; /** * Demonstrates the three most commonly confused crypto operations in production: * 1. AES-GCM β confidentiality + integrity (encrypt sensitive data) * 2. HMAC-SHA256 β integrity only (verify a message hasn't been tampered with) * 3. RSA-PSS β digital signature (prove who sent a message, non-repudiation) * * These are NOT interchangeable. Using the wrong one for the job is a security vulnerability. */ public class EncryptionStrategySelector { // ------------------------------------------------------------------------- // Use Case 1: HMAC-SHA256 for webhook payload integrity verification. // GitHub, Stripe, Twilio all use HMAC-SHA256 for webhook signatures. // This does NOT encrypt the payload β it only lets the receiver verify it wasn't modified. // ------------------------------------------------------------------------- /** * Generates an HMAC-SHA256 signature for a webhook payload. * The secret is shared out-of-band (configured in your dashboard, stored in Secrets Manager). * This is identical to how Stripe verifies webhook events. * * @param webhookPayload the raw JSON body as a string (must be the exact bytes, before any parsing) * @param sharedSecret the webhook signing secret from your secrets manager * @return hex-encoded HMAC signature to compare against the inbound X-Signature header */ public static String computeWebhookSignature(String webhookPayload, byte[] sharedSecret) throws Exception { // HMAC-SHA256 requires a symmetric secret β both sides must have it. // It provides integrity and authentication but NOT confidentiality. // The payload is still readable by anyone who intercepts it β use TLS for confidentiality. Mac hmac = Mac.getInstance("HmacSHA256"); SecretKeySpec signingKey = new SecretKeySpec(sharedSecret, "HmacSHA256"); hmac.init(signingKey); byte[] signatureBytes = hmac.doFinal(webhookPayload.getBytes(StandardCharsets.UTF_8)); // Convert to hex string β most webhook providers use hex, some use Base64 StringBuilder hexBuilder = new StringBuilder(); for (byte b : signatureBytes) { hexBuilder.append(String.format("%02x", b)); } return hexBuilder.toString(); } /** * Verifies a webhook signature using constant-time comparison. * CRITICAL: Do NOT use .equals() for signature comparison β it short-circuits on first * mismatch, creating a timing side-channel that lets attackers guess signatures byte by byte. * MessageDigest.isEqual() runs in constant time regardless of where the mismatch occurs. */ public static boolean verifyWebhookSignature(String payload, byte[] sharedSecret, String expectedSignature) throws Exception { String computedSignature = computeWebhookSignature(payload, sharedSecret); // MessageDigest.isEqual() is constant-time β this is not optional return MessageDigest.isEqual( computedSignature.getBytes(StandardCharsets.UTF_8), expectedSignature.getBytes(StandardCharsets.UTF_8) ); } // ------------------------------------------------------------------------- // Use Case 2: RSA-PSS Digital Signature for document signing. // Use this when you need non-repudiation: proof that a specific private key signed a document. // JWT RS256 tokens, code signing, and contract signing all use this pattern. // ------------------------------------------------------------------------- /** * Signs a document (e.g., a financial transaction record) with RSA-PSS. * PSS (Probabilistic Signature Scheme) is what you use β PKCS#1 v1.5 signatures * have known vulnerabilities (e.g., Bleichenbacher's 2006 attack on RSA-PKCS1-v1_5 sigs). * * @param documentJson the document content to sign as a JSON string * @param signingKey the signer's RSA private key β lives in HSM in production * @return Base64-encoded signature to store alongside the document */ public static String signDocument(String documentJson, PrivateKey signingKey) throws Exception { Signature rsaPss = Signature.getInstance("RSASSA-PSS"); // SHA-256 with PSS parameters β explicitly specify rather than relying on JVM defaults java.security.spec.PSSParameterSpec pssParams = new java.security.spec.PSSParameterSpec( "SHA-256", // hash algorithm "MGF1", // mask generation function java.security.spec.MGF1ParameterSpec.SHA256, 32, // salt length in bytes β should match hash output length (SHA-256 = 32 bytes) 1 // trailer field β always 1 per RFC 8017 ); rsaPss.setParameter(pssParams); rsaPss.initSign(signingKey); rsaPss.update(documentJson.getBytes(StandardCharsets.UTF_8)); byte[] signatureBytes = rsaPss.sign(); return Base64.getEncoder().encodeToString(signatureBytes); } /** * Verifies an RSA-PSS signature using the signer's public key. * Anyone with the public key can verify β only the private key holder can sign. * This is non-repudiation: the signer can't later claim they didn't sign it. */ public static boolean verifyDocumentSignature(String documentJson, String signatureBase64, PublicKey verifyingKey) throws Exception { Signature rsaPss = Signature.getInstance("RSASSA-PSS"); java.security.spec.PSSParameterSpec pssParams = new java.security.spec.PSSParameterSpec( "SHA-256", "MGF1", java.security.spec.MGF1ParameterSpec.SHA256, 32, 1 ); rsaPss.setParameter(pssParams); rsaPss.initVerify(verifyingKey); rsaPss.update(documentJson.getBytes(StandardCharsets.UTF_8)); return rsaPss.verify(Base64.getDecoder().decode(signatureBase64)); } public static void main(String[] args) throws Exception { System.out.println("=== Scenario 1: Webhook Signature Verification (Stripe-style) ==="); String webhookBody = "{\"event\":\"payment.completed\",\"amount\":9999,\"currency\":\"USD\"}"; byte[] webhookSecret = "whsec_prod_k9mXv3Rp2nTqLwYs".getBytes(StandardCharsets.UTF_8); String signature = computeWebhookSignature(webhookBody, webhookSecret); System.out.println("Webhook signature (hex): " + signature.substring(0, 20) + "..."); boolean valid = verifyWebhookSignature(webhookBody, webhookSecret, signature); System.out.println("Signature valid: " + valid); // Simulate tampered payload boolean tamperedValid = verifyWebhookSignature( webhookBody.replace("9999", "1"), webhookSecret, signature ); System.out.println("Tampered payload signature valid: " + tamperedValid); System.out.println("\n=== Scenario 2: Document Signing (RSA-PSS) ==="); KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA"); rsaGen.initialize(2048, new SecureRandom()); KeyPair contractSigningKeys = rsaGen.generateKeyPair(); String loanAgreement = "{\"loanId\":\"LN-44821\",\"borrower\":\"Alice Smith\",\"amount\":250000,\"rate\":\"4.5%\"}"; String docSignature = signDocument(loanAgreement, contractSigningKeys.getPrivate()); System.out.println("Document signature (Base64): " + docSignature.substring(0, 30) + "..."); boolean sigValid = verifyDocumentSignature(loanAgreement, docSignature, contractSigningKeys.getPublic()); System.out.println("Signature valid: " + sigValid); boolean alteredSigValid = verifyDocumentSignature( loanAgreement.replace("250000", "1"), docSignature, contractSigningKeys.getPublic() ); System.out.println("Altered document signature valid: " + alteredSigValid); } }
Webhook signature (hex): a7f3c912e084b621d9...
Signature valid: true
Tampered payload signature valid: false
=== Scenario 2: Document Signing (RSA-PSS) ===
Document signature (Base64): mP3kQr9XnV2wLsYt4uBz...
Signature valid: true
Altered document signature valid: false
| Algorithm / Mode | Key Size | Block/Key Type | Speed (relative) | Provides Integrity? | Use Case | Avoid When |
|---|---|---|---|---|---|---|
| DES | 56-bit effective | Symmetric, block (64-bit block) | Fast | No | Migrating legacy data only | Always β broken since 1998 |
| 3DES (2-key) | 112-bit effective | Symmetric, block (64-bit block) | ~3x slower than AES | No | Legacy PCI systems pre-2023 | New systems; SWEET32 risk above 32GB |
| AES-128-GCM | 128-bit | Symmetric, block (128-bit block) | Fastest (HW accel) | Yes (AEAD) | TLS session data, low-sensitivity at-rest | Long-lived keys with post-quantum concerns |
| AES-256-GCM | 256-bit | Symmetric, block (128-bit block) | Fast (HW accel) | Yes (AEAD) | Database field encryption, file encryption, PII | When you need asymmetric key exchange |
| AES-128-CBC | 128-bit | Symmetric, block (128-bit block) | Fast | No | Legacy decryption only | New code β padding oracles, no integrity |
| AES-256-ECB | 256-bit | Symmetric, block (128-bit block) | Fastest | No | Never | Everything β leaks plaintext patterns |
| RSA-2048-OAEP | 2048-bit | Asymmetric | ~100x slower than AES | No (encrypt only) | Wrapping AES keys, TLS handshake | Encrypting large payloads directly |
| RSA-4096-PSS | 4096-bit | Asymmetric (signature) | Slowest | Signature = integrity | Long-lived document signing, CA certificates | High-throughput per-request signing |
| ECDH P-256 | 256-bit | Asymmetric (key agreement) | Fast | No (key exchange only) | Replacing RSA key exchange, TLS 1.3 | When you need encryption, not just key agreement |
| HMAC-SHA256 | β₯256-bit secret | Symmetric (MAC) | Very fast | Yes (MAC only) | Webhook signatures, API request signing, JWT HS256 | When you need confidentiality β HMAC doesn't encrypt |
π― Key Takeaways
- AES-GCM is authenticated encryption β it gives you both confidentiality and tamper detection in one operation. AES-CBC gives you confidentiality only and is vulnerable to padding oracle attacks. In new code, the choice is AES/GCM/NoPadding, full stop.
- Nonce reuse in AES-GCM doesn't weaken the encryption β it destroys it. Two ciphertexts under the same key and nonce can be XORed to recover both plaintexts. Always generate nonces with SecureRandom and never derive them from resettable state like database sequences.
- Reach for hybrid encryption (RSA wraps AES key, AES encrypts payload) when two parties need to establish a secure channel without a pre-shared secret β this is what TLS, PGP, and Signal all do. Using RSA to directly encrypt payloads is a design smell and fails with anything over ~190 bytes under RSA-2048-OAEP.
- Encryption, HMAC, and password hashing are not interchangeable tools. Encrypting passwords instead of hashing them means a key compromise exposes every password simultaneously. Hashing secrets instead of encrypting them means you can never recover the original value legitimately. Pick the right primitive for the job before you write a single line of code.
β Common Mistakes to Avoid
- βMistake 1: Using AES/CBC/PKCS5Padding for new encrypted storage columns β decryption succeeds but silently accepts tampered ciphertext β switch to AES/GCM/NoPadding which throws javax.crypto.AEADBadTagException on any tampering attempt, making attacks detectable instead of invisible.
- βMistake 2: Hardcoding the AES encryption key as a static final byte[] in application source code β symptoms: key is readable in decompiled .class files, leaked in git history, baked into Docker images β fix: retrieve keys exclusively from AWS KMS, HashiCorp Vault, or Azure Key Vault at runtime; the key must never exist as plaintext in your repository or on disk.
- βMistake 3: Using a predictable or sequential nonce for AES-GCM (e.g., incrementing counter from a DB sequence or timestamp in milliseconds) β nonce collisions across application restarts, failovers, or DB restores destroy GCM's security entirely, allowing plaintext recovery β fix: always generate nonces with SecureRandom and prepend them to the ciphertext; never derive them from any external state.
- βMistake 4: Encrypting passwords with AES instead of hashing with Argon2id β encrypted passwords can be decrypted if the key is compromised, giving attackers all plaintext passwords simultaneously β fix: use password_hash() in PHP, Argon2id via Spring Security in Java, or bcrypt.hashpw() in Python; encryption is not a substitute for proper password storage.
- βMistake 5: Using String.equals() to compare HMAC signatures in webhook handlers β creates a timing side-channel where response time leaks how many bytes of the signature are correct, enabling byte-by-byte forgery attacks β fix: use MessageDigest.isEqual() in Java, hmac.compare_digest() in Python, or hash_equals() in PHP for all cryptographic comparisons.
Interview Questions on This Topic
- QAES-GCM uses a 96-bit nonce and a 128-bit authentication tag. If you're encrypting billions of records under a single AES key using random nonces, what's the birthday bound at which nonce collision probability becomes unacceptable, and what's the standard mitigation strategy used in systems like Google Cloud Storage?
- QA service needs to let clients upload encrypted files that only they can decrypt β the server should never see plaintext. Would you use RSA, AES, or a combination, and how would you handle key rotation when a user changes their password?
- QYour team is using RSA-2048 with PKCS#1 v1.5 padding for wrapping session keys in a messaging service. A security audit flags this as a Bleichenbacher vulnerability. Walk me through why PKCS#1 v1.5 is exploitable, what an attacker can actually do with a padding oracle in practice, and the exact change needed to mitigate it.
- QYou need to sign API requests between microservices so the receiving service can verify the request came from a trusted caller and wasn't tampered with in transit. TLS is already in place. Would you use RSA-PSS signatures, HMAC-SHA256, or something else β and what determines that choice in a high-throughput, horizontally-scaled environment?
Frequently Asked Questions
Is AES-256 actually more secure than AES-128 in practice?
For practical purposes, no β both are computationally unbreakable with current technology. AES-128 has a 2^128 keyspace; a brute-force attack is impossible with all the energy on Earth. AES-256 adds a 128-bit margin against future quantum attacks via Grover's algorithm, which halves the effective key strength, giving you 128 bits of post-quantum security instead of 64. If your data needs to remain secret for 20+ years or you're operating under NIST post-quantum guidance, use AES-256. For everything else, AES-128-GCM and AES-256-GCM are both fine β the mode matters far more than the key size.
What's the difference between encryption and hashing, and when do I use each?
Encryption is reversible β you can get the original value back with the right key. Hashing is one-way β you cannot recover the original input. Use encryption when you need to store and retrieve a value (credit card numbers, SSNs, medical records). Use hashing when you only need to verify a value without ever recovering it (passwords, data integrity checks). The classic mistake is hashing data you later need to read, or encrypting passwords instead of using a proper slow hash like Argon2id β which means a key leak hands the attacker every password in plaintext.
How do I securely store an AES encryption key in a Java application?
You don't store it in the application at all. The key should live in a dedicated key management service β AWS KMS, HashiCorp Vault, Google Cloud KMS, or Azure Key Vault. Your application calls the KMS API to encrypt (wrap) data keys or to decrypt ciphertext; the root key never leaves the HSM. At minimum, load the key from an environment variable injected by your secrets management system at container startup, never from source code or a config file. If you find a SecretKey or byte[] key hardcoded anywhere in your codebase, treat it as a confirmed breach β rotate immediately.
We're seeing intermittent javax.crypto.AEADBadTagException in production decryption β what causes it and is it always an attack?
Not always an attack, but always a data integrity problem β and you should treat it seriously until you've ruled out malice. The three most common non-malicious causes are: (1) nonce reuse due to a counter reset after a service restart or database restore, causing the decryption to fail on data encrypted before the reset; (2) data corruption in transit or storage where a single bit was flipped β GCM caught it, which is the system working correctly; (3) key mismatch where the encrypting instance used a different key version than the decrypting instance after a key rotation without versioning the ciphertext. The fix for case 3 is always prepend a key version identifier to your encrypted blobs so the decryptor knows which key version to request from the KMS.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.