Cryptography & Encryption in Spring
Applied cryptography without inventing anything: choosing AES-GCM, envelope encryption, encrypting database columns with an AttributeConverter, and Vault Transit for key management.
On this page
Applied cryptography in an application is mostly about not inventing anything: pick a well-analysed construction, use a library correctly, and put the difficult part — key management — somewhere built for it.
Key Takeaways
- Use AES-256-GCM. It authenticates as well as encrypts, which CBC does not.
- Never reuse an IV with the same key. Generate one per encryption and store it with the ciphertext.
- Envelope encryption means a compromised data key does not expose everything.
- Encrypting a column makes it unsearchable — plan for that before you encrypt it.
- Prefer a KMS or Vault Transit over managing keys in your application.
Symmetric encryption done correctly
@Component
public class AesGcmEncryptor {
private static final int IV_LENGTH = 12; // 96 bits, the GCM standard
private static final int TAG_LENGTH = 128; // bits
private final SecretKey key;
private final SecureRandom random = new SecureRandom();
public String encrypt(String plaintext) {
try {
// A fresh random IV every time. Reusing one with the same key
// catastrophically breaks GCM.
byte[] iv = new byte[IV_LENGTH];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// The IV is not secret, but it must travel with the ciphertext.
ByteBuffer buffer = ByteBuffer.allocate(iv.length + ciphertext.length);
buffer.put(iv).put(ciphertext);
return Base64.getEncoder().encodeToString(buffer.array());
} catch (GeneralSecurityException ex) {
throw new EncryptionException("encryption failed", ex);
}
}
public String decrypt(String encoded) {
try {
ByteBuffer buffer = ByteBuffer.wrap(Base64.getDecoder().decode(encoded));
byte[] iv = new byte[IV_LENGTH];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
// AEADBadTagException here means the ciphertext was tampered with —
// which is exactly what GCM gives you over CBC.
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (GeneralSecurityException ex) {
throw new EncryptionException("decryption failed", ex);
}
}
}Spring's Encryptors.stronger(password, salt) wraps AES-GCM for the simple case and is worth
preferring when it fits, because there is no IV handling to get wrong.
One thing to add before this reaches production: a key version marker. Ciphertext written today will outlive the key that produced it, and a stored value with no way to say which key it used forces a big-bang rotation in which everything must be re-encrypted at once, under a maintenance window. Prefixing a version byte or a short key id to the payload lets decryption select the right key and rotation proceed record by record, in the background, with both keys live.
GCM's additional authenticated data is also worth using for column encryption. Passing the row's primary key as AAD binds the ciphertext to that row, so an attacker with database write access can no longer copy an encrypted balance from one account into another and have it decrypt cleanly. The AAD is not stored — it is recomputed at decryption time — and a mismatch fails exactly like tampering.
Envelope encryption
public EncryptedPayload encrypt(byte[] plaintext) {
// KMS returns the data key twice: plaintext to use now, encrypted to store.
GenerateDataKeyResponse dataKey = kms.generateDataKey(b -> b
.keyId(masterKeyId)
.keySpec(DataKeySpec.AES_256));
byte[] ciphertext = localAesGcm(dataKey.plaintext().asByteArray(), plaintext);
// Wipe the plaintext key from memory as soon as it is used.
Arrays.fill(dataKey.plaintext().asByteArray(), (byte) 0);
return new EncryptedPayload(ciphertext, dataKey.ciphertextBlob().asByteArray());
}Two properties make this the standard pattern. Bulk data never crosses the network to the KMS, so throughput is local-CPU bound rather than API-limited. And rotating the master key re-wraps a small number of data keys instead of re-encrypting terabytes.
Encrypting a column
@Converter
@Component
public class EncryptedStringConverter implements AttributeConverter<String, String> {
private static AesGcmEncryptor encryptor; // static injection: JPA instantiates this
@Autowired
public void setEncryptor(AesGcmEncryptor encryptor) {
EncryptedStringConverter.encryptor = encryptor;
}
@Override
public String convertToDatabaseColumn(String attribute) {
return attribute == null ? null : encryptor.encrypt(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
return dbData == null ? null : encryptor.decrypt(dbData);
}
}
@Entity
public class Customer {
@Convert(converter = EncryptedStringConverter.class)
private String nationalInsuranceNumber;
@Convert(converter = EncryptedStringConverter.class)
private String bankAccountNumber;
}The static field is not elegant, and it is there because Hibernate instantiates converters itself
rather than taking them from the Spring context. It carries one practical consequence: the converter
cannot work before the context has finished starting, which surfaces if a Flyway callback or an early
@PostConstruct touches an encrypted entity.
The consequence to plan for: an encrypted column is not searchable. WHERE national_insurance_number = ? cannot match, because every encryption produces different ciphertext thanks to the random IV. You
also cannot index it usefully, sort by it, or join on it.
Where lookup is needed, store a blind index alongside — a keyed HMAC of the normalised value, which is deterministic and therefore searchable for exact matches while not revealing the plaintext. Accept that this leaks equality: two records with the same value have the same index.
Vault Transit
@Service
public class VaultTransitService {
private final VaultTemplate vault;
public String encrypt(String plaintext) {
// The key never leaves Vault. Your application never holds it, so a
// heap dump or a compromised process does not expose it.
return vault.opsForTransit()
.encrypt("customer-pii", Base64.getEncoder()
.encodeToString(plaintext.getBytes(StandardCharsets.UTF_8)));
}
public String decrypt(String ciphertext) {
String decoded = vault.opsForTransit().decrypt("customer-pii", ciphertext);
return new String(Base64.getDecoder().decode(decoded), StandardCharsets.UTF_8);
}
/** Re-encrypt to the latest key version WITHOUT seeing the plaintext. */
public String rewrap(String ciphertext) {
return vault.opsForTransit().rewrap("customer-pii", ciphertext);
}
}rewrap is the feature that makes key rotation practical. Vault re-encrypts existing ciphertext to a
new key version without ever exposing the plaintext to your application — so rotating a key is a batch
job over ciphertext, not a decrypt-and-re-encrypt pipeline that puts every secret through your JVM.
The cost is a network call per operation. For high-volume encryption, use envelope encryption with Transit protecting the data keys.
Hashing versus encryption
They solve different problems and confusing them is a common error.
Hash when you never need the original: passwords, integrity checks, deduplication keys. Use a slow, salted, adaptive function — Argon2id or BCrypt — for passwords, and a fast one like SHA-256 for integrity.
Encrypt when you must recover the plaintext: personal data, payment details, documents.
Encrypting a password is a bug, because anyone who obtains the key obtains every password. Hashing a national insurance number you later need to display is equally wrong in the other direction.
What to take away
Use AES-256-GCM with a fresh IV per encryption, or Spring's Encryptors.stronger so there is nothing
to get wrong. Adopt envelope encryption so bulk data stays local and rotation is cheap. Remember that
encrypted columns cannot be searched, and add a blind index where lookup is needed. Put the keys in a
KMS or Vault Transit rather than in your application.
Frequently Asked Questions
Why AES-GCM rather than AES-CBC?
Can I reuse an IV?
Should I encrypt the whole database or specific columns?
Related tutorials
- Attribute-Based Access Control (ABAC)When roles are not enough: the PDP/PEP model, Open Policy Agent and Rego, integrating OPA with Spring, and deciding between ABAC and a richer RBAC.
- Role-Based Access Control (RBAC)Authorisation with roles: HTTP versus method security, role hierarchies, @PreAuthorize and @PostAuthorize, custom PermissionEvaluator, and where RBAC stops being enough.
- LDAP & Active Directory IntegrationAuthenticating against a corporate directory: LDAP structure, bind versus password comparison, ActiveDirectoryLdapAuthenticationProvider, group-to-role mapping and LDAPS.
- SAML 2.0 AuthenticationEnterprise SSO with SAML: the SP-initiated flow step by step, RelyingPartyRegistration, the assertion checks that matter, metadata exchange and single logout.