DEV Community

Md. Rakibul Hasan
Md. Rakibul Hasan

Posted on

Encrypting Sensitive Data in Spring Boot: AES-256-GCM Explained with a Real-World Example

When building an insurance application, we often store sensitive information such as a driver's date of birth (DOB), driver's license number, phone number, and other personally identifiable information (PII).

But what happens if someone gets access to our database?

Database authentication and access control are important, but they do not necessarily protect data from someone who obtains a database dump or gains unauthorized direct database access.

One way to add another layer of protection is application-level encryption.

In this article, we'll explore how AES-256-GCM works, how encryption keys are managed, and how to implement field-level encryption in a Spring Boot application.

1. The Problem: Storing Sensitive Data in Plaintext

Imagine our insurance application stores driver information in PostgreSQL.

A database record might look like this:

id date_of_birth driver_license
101 2000-05-15 D1234567

The application can read this information directly from the database. Unfortunately, anyone who obtains an unauthorized copy of the database can read it too.

We could hash these values, but hashing is a one-way operation. We need to recover a driver's original date of birth when calculating age or performing underwriting checks.

Therefore, we need encryption, not ordinary hashing.

2. Encryption vs. Hashing

These two concepts serve different purposes.

Encryption transforms readable data into ciphertext using a key. The original data can be recovered using the appropriate decryption key.

Hashing transforms data into a fixed-length digest. A cryptographic hash is designed to be one-way, so the original input cannot practically be recovered from the hash.

For our insurance application:

  • DOB: Use encryption because the application may need the original value.
  • Driver's license number: Use encryption if the original value must be recovered.
  • Passwords: Use a dedicated password-hashing algorithm such as Argon2id, not reversible encryption.

Now let's look at how encryption works.

3. How AES-256-GCM Works

AES stands for Advanced Encryption Standard. It is a symmetric encryption algorithm, meaning the same secret key is used for encryption and decryption.

AES-256 uses a 256-bit key, which is 32 bytes long.

GCM stands for Galois/Counter Mode. It is an authenticated encryption mode that provides two important security properties:

  1. Confidentiality: Unauthorized readers cannot recover the plaintext without the key.
  2. Integrity and authenticity: Modifications to the ciphertext are detected during decryption.

Let's walk through the process.

Step 1: Start with plaintext

Suppose the user enters the following information:

DOB: 2000-05-15
Driver's License: D1234567
Enter fullscreen mode Exit fullscreen mode

This is plaintext: the original, readable information.

Step 2: Generate a random IV

Before encrypting the data, the application generates a random 12-byte initialization vector (IV).

An IV is a value used by the encryption algorithm to ensure that encrypting the same plaintext with the same key does not normally produce the same ciphertext.

For example, encrypting the same license number twice should produce different encrypted outputs.

The IV is not a secret. It can be stored alongside the ciphertext.

However, with AES-GCM, an IV must never be reused with the same encryption key.

Step 3: Encrypt the plaintext

The application passes three things to the encryption algorithm:

  • The plaintext.
  • The secret AES-256 key.
  • The randomly generated IV.

AES-GCM produces ciphertext and an authentication tag.

Conceptually:

Plaintext + Secret Key + Random IV
                  |
                  v
             AES-256-GCM
                  |
                  v
       Ciphertext + Authentication Tag
Enter fullscreen mode Exit fullscreen mode

The authentication tag allows the application to detect tampering when decrypting the data.

Step 4: Store the encrypted result

The application stores the IV, ciphertext, and authentication tag together, usually encoded as Base64 for convenient database storage.

The database might now contain:

id dob_encrypted license_encrypted
101 Base64-encoded ciphertext Base64-encoded ciphertext

The values shown here are illustrative; actual encrypted values are longer and look like opaque encoded strings.

Base64 is not encryption. It is only an encoding format for storing binary data in a text column.

The security comes from AES-GCM and proper key management, not from Base64.

4. Where Should the Encryption Key Be Stored?

This is one of the most important parts of the design.

Suppose we encrypt every driver's DOB and license number but store the encryption key in the same database.

An attacker who obtains both the database records and the key can decrypt the data.

That defeats much of the intended protection.

Instead, keep the key separate from the database.

For example:

                 Spring Boot
                      |
          +-----------+-----------+
          |                       |
          v                       v
    PostgreSQL              Key Management
    Encrypted data           Secret key
Enter fullscreen mode Exit fullscreen mode

For a small deployment, a protected environment variable can be a starting point. In a production environment, a managed Key Management Service (KMS) or secrets-management system provides stronger separation and additional operational controls.

The encryption key should never be committed to Git, embedded in the Docker image, or printed in application logs.

You must also maintain secure backups of the key. If the key is permanently lost, the corresponding encrypted data may become unrecoverable.

5. Implementing Encryption in Spring Boot

Java provides cryptographic APIs through the Java Cryptography Architecture (JCA), so we do not need an external library just to use AES-GCM.

First, generate a random 256-bit key in a trusted environment:

openssl rand -base64 32
Enter fullscreen mode Exit fullscreen mode

Store the generated value securely and expose it to Spring Boot through your deployment's secret configuration.

In application.yml:

app:
  encryption:
    key: ${QUOTE_ENCRYPTION_KEY}
Enter fullscreen mode Exit fullscreen mode

Next, create a service responsible for encryption and decryption.

@Service
public class FieldEncryptionService {

    private static final String TRANSFORMATION =
            "AES/GCM/NoPadding";

    private static final int IV_LENGTH = 12;
    private static final int TAG_LENGTH = 128;

    private final SecretKey key;
    private final SecureRandom secureRandom =
            new SecureRandom();

    public FieldEncryptionService(
            @Value("${app.encryption.key}") String base64Key) {

        byte[] keyBytes =
                Base64.getDecoder().decode(base64Key);

        if (keyBytes.length != 32) {
            throw new IllegalArgumentException(
                    "AES-256 requires a 32-byte key");
        }

        this.key = new SecretKeySpec(keyBytes, "AES");
    }

    public String encrypt(String plaintext) {
        if (plaintext == null) {
            return null;
        }

        try {
            byte[] iv = new byte[IV_LENGTH];
            secureRandom.nextBytes(iv);

            Cipher cipher =
                    Cipher.getInstance(TRANSFORMATION);

            cipher.init(
                    Cipher.ENCRYPT_MODE,
                    key,
                    new GCMParameterSpec(TAG_LENGTH, iv)
            );

            byte[] ciphertext = cipher.doFinal(
                    plaintext.getBytes(StandardCharsets.UTF_8)
            );

            ByteBuffer buffer = ByteBuffer.allocate(
                    iv.length + ciphertext.length
            );

            buffer.put(iv);
            buffer.put(ciphertext);

            return Base64.getEncoder()
                    .encodeToString(buffer.array());

        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(
                    "Encryption failed", e);
        }
    }

    public String decrypt(String encryptedValue) {
        if (encryptedValue == null) {
            return null;
        }

        try {
            byte[] combined = Base64.getDecoder()
                    .decode(encryptedValue);

            if (combined.length <= IV_LENGTH + TAG_LENGTH / 8) {
                throw new IllegalArgumentException(
                        "Invalid encrypted payload");
            }

            ByteBuffer buffer = ByteBuffer.wrap(combined);

            byte[] iv = new byte[IV_LENGTH];
            buffer.get(iv);

            byte[] ciphertext = new byte[buffer.remaining()];
            buffer.get(ciphertext);

            Cipher cipher =
                    Cipher.getInstance(TRANSFORMATION);

            cipher.init(
                    Cipher.DECRYPT_MODE,
                    key,
                    new GCMParameterSpec(TAG_LENGTH, iv)
            );

            byte[] plaintext = cipher.doFinal(ciphertext);

            return new String(
                    plaintext,
                    StandardCharsets.UTF_8
            );

        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(
                    "Decryption failed", e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The imports are omitted for readability. They include the relevant javax.crypto, java.security, java.nio, java.nio.charset, and java.util classes.

Notice that the IV is stored together with the ciphertext. During decryption, the application extracts the IV, reconstructs the GCM parameters, and verifies the authentication tag as part of doFinal().

If the ciphertext or authentication tag has been modified, decryption fails instead of returning unauthenticated plaintext.

In a production implementation, malformed Base64, authentication failures, and other decryption errors should be handled safely without exposing sensitive information in logs or API responses.

6. Encrypt the Data Before Saving It

Now let's connect encryption to our insurance quote workflow.

Suppose our database entity stores encrypted values as strings:

@Entity
@Table(name = "quote_driver")
public class QuoteDriver {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "dob_encrypted")
    private String dobEncrypted;

    @Column(name = "license_encrypted")
    private String licenseEncrypted;

    // Other fields, constructors, and accessors
}
Enter fullscreen mode Exit fullscreen mode

When a quote is submitted, the service encrypts the values before persistence.

public QuoteDriver saveDriver(
        LocalDate dob,
        String licenseNumber) {

    QuoteDriver driver = new QuoteDriver();

    driver.setDobEncrypted(
            encryptionService.encrypt(dob.toString())
    );

    driver.setLicenseEncrypted(
            encryptionService.encrypt(licenseNumber)
    );

    return quoteDriverRepository.save(driver);
}
Enter fullscreen mode Exit fullscreen mode

The flow becomes:

Angular Form
     |
     v
Spring Boot API
     |
     v
Validate Input
     |
     v
Encrypt Sensitive Fields
     |
     v
Save to PostgreSQL
Enter fullscreen mode Exit fullscreen mode

The browser does not need to encrypt these fields with the server's encryption key. The server performs encryption inside its trusted application boundary after receiving the request over HTTPS.

When an authorized operation needs the original DOB, the service decrypts it and converts it back to a LocalDate.

LocalDate dob = LocalDate.parse(
        encryptionService.decrypt(
                driver.getDobEncrypted()
        )
);
Enter fullscreen mode Exit fullscreen mode

The same approach applies to the driver's license number.

Keep decrypted values inside the business-service boundary where possible. Do not automatically expose them in API responses simply because they have been decrypted.

7. What Happens When We Need to Validate a Driver's Age?

This is an important consideration for insurance systems.

Suppose the business rule requires drivers to be at least 18 years old.

Because DOB is encrypted in the database, we cannot directly perform ordinary date comparisons against that ciphertext.

Instead, the application retrieves the encrypted value, decrypts it in the authorized service, and performs the validation:

LocalDate dob = LocalDate.parse(
        encryptionService.decrypt(
                driver.getDobEncrypted()
        )
);

int age = Period.between(
        dob,
        LocalDate.now()
).getYears();

if (age < 18) {
    throw new IllegalArgumentException(
            "Driver must be at least 18 years old");
}
Enter fullscreen mode Exit fullscreen mode

The example demonstrates the flow; production validation should use the application's agreed business rules, clock and timezone.

Encryption does not remove the need for normal validation. It changes how sensitive information is stored and accessed.

If the application needs frequent age checks, carefully evaluate whether the original DOB must be retrieved each time or whether an appropriate derived value can meet the requirement. Any derived field should be designed to avoid introducing unnecessary privacy risks.

8. Can We Search Encrypted Data?

Randomized AES-GCM encryption creates a challenge for database queries.

Consider the same license number encrypted twice:

D1234567 -> Ciphertext A
D1234567 -> Ciphertext B
Enter fullscreen mode Exit fullscreen mode

The ciphertexts differ because the IVs differ.

Consequently, a normal SQL query cannot find a license number by comparing the plaintext against the encrypted column.

If exact matching is necessary, a separate keyed HMAC-SHA-256 lookup value can be stored alongside the encrypted license number. The HMAC key must be independent of the encryption key.

The application can calculate the HMAC for a search request and compare it with the stored lookup value without decrypting every record.

This design requires careful handling of key rotation, normalization and access control. HMAC lookup values also reveal equality: records with the same normalized input produce the same lookup value under the same key.

Do not add searchable derived fields unless the business actually requires them.

9. Common Mistakes to Avoid

1. Hardcoding the encryption key

Never commit a real key to Git or embed it in source code.

2. Reusing the IV

Generate a fresh, cryptographically random IV for every encryption under the same AES-GCM key.

3. Assuming Base64 is encryption

Base64 can be decoded by anyone. It does not provide confidentiality.

4. Logging plaintext

Application logs, exception messages, debugging statements and monitoring systems can inadvertently expose sensitive information.

5. Forgetting key rotation

Encryption keys have a lifecycle. Store a key version with encrypted data or use a versioned ciphertext format so the application can identify which key is required for decryption.

6. Ignoring existing database records

Adding encryption to a new entity does not automatically encrypt previously stored plaintext. Existing records need a controlled migration, verification and removal of the original plaintext values.

7. Decrypting everything unnecessarily

Decrypt sensitive data only when an authorized operation needs it. Apply role-based access controls and audit access to sensitive fields.

10. Is Application-Level Encryption Enough?

Application-level encryption is one layer of a broader security strategy.

For a production insurance application, consider combining:

  • HTTPS/TLS for data in transit.
  • AES-256-GCM for sensitive fields at the application layer.
  • A managed KMS or vault for encryption-key management.
  • Database access controls and encryption at rest.
  • Secure backups and recovery procedures.
  • Least-privilege access, audit logging and data-retention policies.
  • Protection against sensitive information appearing in logs, traces and API responses.

Also remember that if an attacker compromises the running application and can access both the encryption key and the decryption functionality, they may still obtain plaintext. Encryption does not replace application security.

Conclusion

Protecting sensitive insurance data requires more than securing database credentials.

With AES-256-GCM, Spring Boot can encrypt DOB and driver's license numbers before saving them to PostgreSQL and decrypt them when authorized business operations need the original values.

The essential principles are straightforward:

  1. Encrypt sensitive fields before persistence.
  2. Use authenticated encryption with a fresh IV for every operation.
  3. Keep encryption keys separate from the encrypted database.
  4. Control decryption through the application’s authorization layer.
  5. Plan for key rotation, migration, backups and data retention.

The result is a stronger security boundary: someone who obtains only the database contents cannot directly read the original sensitive values.

Further reading: OWASP Cryptographic Storage Cheat Sheet and Java Cryptography Architecture (JCA) documentation.

Top comments (1)

Collapse
 
the_saint_dac63e343ee8704 profile image
The Saint •

Good walkthrough of the IV handling. One thing worth adding to the ciphertext format is a key version prefix, so rotation can be done lazily: new writes use the new key and old rows still decrypt until a background job re-encrypts them. Also worth checking that your backups and any restored copies are encrypted with the same discipline, since a plaintext dump after a migration undoes all of this. And test a restore with the key actually available, because that is where rotation setups usually break.