Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced6 min readUpdated
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

AesGcmEncryptor.java
@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

Bulk data is encrypted locally with a data key; only the small data key travels to the KMS. Rotating the master key means re-wrapping keys, not re-encrypting data.
EnvelopeEncryption.java
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

EncryptedStringConverter.java
@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

VaultTransitService.java
@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?
GCM is authenticated encryption — it detects tampering as well as providing confidentiality. CBC without a separate MAC lets an attacker modify ciphertext in ways that produce predictable plaintext changes, and the padding oracle attack against CBC is well known and practical. Use GCM.
Can I reuse an IV?
Never with GCM. Reusing a nonce with the same key breaks the security of the mode completely — an attacker who sees two messages encrypted with the same key and nonce can recover the authentication key. Generate a fresh random IV per encryption and store it alongside the ciphertext.
Should I encrypt the whole database or specific columns?
Both address different threats. Transparent disk encryption protects against a stolen disk or backup and is nearly free. Column encryption protects against anyone with database access, including a compromised application query or a curious DBA. Encrypt the columns that would matter in a breach.

Related tutorials