1. Overview
In building web applications, we sometimes need to store sensitive information in a database.
This can either be an API key, credit card details, or client secret keys.
Storing such data in plain text poses significant security risks, making encryption essential.
In this article, we are going to learn how to automatically encrypt and decrypt sensitive data, stored in a database,
in a SpringBoot application using AES and JPA attribute converter.
2. Project Setup
The project we will be creating is a Spring Boot application that stores sensitive client secret keys in a database table.
It will use AES (Advance Encryption Standard) to encrypt the data.
Data encryption will happen automatically while saving the data and
decryption will happen when reading the data, back from the database.
The application connects to a PostgreSQL database, and uses Flyway to create the required database table.
The complete source code will be available at the end of this tutorial.
3. Automatic Database Column Encryption and Decryption
Let’s start by creating the UserSecretKey
model, that maps to the user_secret_keys
table
containing the secret key column.
Listing 3.1 UserSecretKey.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
@Table(name = "user_secret_keys")
@Entity
@SQLRestriction("abolished_at IS NULL")
public class UserSecretKey {
@Id
@SequenceGenerator(name = "user_secret_keys_id_seq", sequenceName = "user_secret_keys_id_seq", allocationSize = 1)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_secret_keys_id_seq")
private long id;
@OneToOne
private User user;
private String hmacSecretKey;
private String clientId;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
private LocalDateTime abolishedAt;
//getters and setters omitted
}
|
The sensitive column we want to encrypt is hmacSecretKey
.
Let’s create helper methods for encrypting and decrypting any data. These are reusable methods that can be invoked whenever we want to
encrypt and decrypt data in AES.
Listing 3.2 CryptoHelper.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
public static String encryptDataAES(String plainData, byte[] key) throws Exception {
SecretKey secretKey = new SecretKeySpec(key, "AES");
//build the initialization vector
byte[] iv = new byte[CRYPTO_IV_LENGTH];
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(iv);
GCMParameterSpec parameterSpec = new GCMParameterSpec(CRYPTO_AUTH_TAG_LENGTH, iv); //128-bit authentication tag length
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
byte[] cipherText = cipher.doFinal(plainData.getBytes());
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
byteBuffer.put(iv);
byteBuffer.put(cipherText);
//the first 12 bytes are the IV where others are the cipher message + authentication tag
byte[] cipherMessage = byteBuffer.array();
return Base64.getEncoder().encodeToString(cipherMessage);
}
public static String decryptDataAES(String encryptedDataInBase64, byte[] key) throws Exception {
SecretKey secretKey = new SecretKeySpec(key, "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
byte[] encryptedDataBytes = Base64.getDecoder().decode(encryptedDataInBase64.getBytes());
//remember we stored the IV as the first 12 bytes while encrypting?
byte[] iv = Arrays.copyOfRange(encryptedDataBytes, 0, CRYPTO_IV_LENGTH);
GCMParameterSpec parameterSpec = new GCMParameterSpec(CRYPTO_AUTH_TAG_LENGTH, iv); //128-bit authentication tag length
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
//use everything from 12 bytes on as ciphertext
byte [] cipherBytes = Arrays.copyOfRange(encryptedDataBytes, CRYPTO_IV_LENGTH, encryptedDataBytes.length);
byte[] plainText = cipher.doFinal(cipherBytes);
return new String(plainText);
}
|
The above functions will help us to encrypt and decrypt any data using AES.
We discussed them extensively in this article.
We will create two more functions, to specifically handle scenarios where the database column is NULL and
other exceptions.
Listing 3.3 CryptoHelper.java
1
2
3
4
5
6
7
8
9
10
11
12
|
public String encryptDeviceCredential(String plainData) {
if(Objects.isNull(plainData) || plainData.isEmpty()) return plainData;
return Try.of(() -> encryptDataAES(plainData, hmacSecretKeyEncryptionKey.getBytes()))
.get();
}
public String decryptDeviceCredential(String cipherText) {
if(Objects.isNull(cipherText) || cipherText.isEmpty()) return cipherText;
return Try.of( () -> decryptDataAES(cipherText, hmacSecretKeyEncryptionKey.getBytes()))
.get();
}
|
The next step is to create a custom implementation of the jakarta.persistence.AttributeConverter
interface.
Listing 3.4 HmacSecretKeyAttributeConverter.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class HmacSecretKeyAttributeConverter
implements AttributeConverter<String, String> {
@Override
public String convertToDatabaseColumn(String attribute) {
CryptoHelper cryptoHelper = SpringContext.getBean(CryptoHelper.class);
return cryptoHelper.encryptDeviceCredential(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
CryptoHelper cryptoHelper = SpringContext.getBean(CryptoHelper.class);
return cryptoHelper.decryptDeviceCredential(dbData);
}
}
|
In Listing 3.4
above, we use SpringContext
to get an instance of the CryptoHelper
class from the Spring Context.
This is because HmacSecretKeyAttributeConverter.java
will be instantiated automatically by JPA/Hibernate, when the application is booting up.
This leaves us with the options to either make the functions cryptoHelper.encryptDeviceCredential
and cryptoHelper.decryptDeviceCredential
static or use the SpringContext
.
The SpringContext
helper class gets uses org.springframework.context.ApplicationContext
to get an instance of CryptoHelper
class. This is possible because CryptoHelper
is a Spring managed bean. It is a Spring managed bean because it is annotated with @Service
.
Finally, the last part of the process is to apply the custom attribute converter to the database column we want.
Listing 3.5 UserSecretKey.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
@Table(name = "user_secret_keys")
@Entity
@SQLRestriction("abolished_at IS NULL")
public class UserSecretKey {
@Id
@SequenceGenerator(name = "user_secret_keys_id_seq", sequenceName = "user_secret_keys_id_seq", allocationSize = 1)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_secret_keys_id_seq")
private long id;
@OneToOne
private User user;
@Convert(converter = HmacSecretKeyAttributeConverter.class)
private String hmacSecretKey;
private String clientId;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
private LocalDateTime abolishedAt;
//getters and setters omitted
}
|
The major changes are in lines 14 and 15 of UserSecretKey.java
above.
We added the @Convert(converter = HmacSecretKeyAttributeConverter.class)
on the hmacSecretKey
column.
This way, anytime we save a new UserSecretKey
record, via its repository, the hmacSecretKey
column
will be automatically encrypted in the database.
Also, when we read the UserSecretKey
, it will be decrypted automatically, making the plain text value available
in memory for other processing.
4. Integration Testing
Let’s validate the setup by creating an integration test that will
save a new UserSecretKey
record with a plain text secret.
We will then read the record, using JdbcTemplate
.
Using JdbcTemplate
means there will be no automatic conversion, and we will be able to
confirm the secret is not stored in plain text.
We will also read the record using the UserSecretKeyRepository
class and assert the automatic
conversion works as expected.
Listing 4.1 UserSecretKeyRepositoryUnitTest.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
@Test
void givenPlainTextSecret_whenSaveUserSecretKey_thenEncryptAndSave() {
String plainTextSecret = "area57PassKey";
String clientId = "demoClient";
//create a new record
UserSecretKey userSecretKey = new UserSecretKey();
userSecretKey.setHmacSecretKey(plainTextSecret);
userSecretKey.setClientId(clientId);
//save the record
userSecretKeyRepository.save(userSecretKey);
//confirm the secret is encrypted
String sql = "SELECT hmac_secret_key FROM user_secret_keys WHERE client_id='%s'"
.formatted(clientId);
String secretKeyInDB = jdbcTemplate.queryForObject(sql, String.class);
//confirm they're not the same thing because secretKeyInDB is
// the encrypted format.
Assertions.assertNotEquals(plainTextSecret, secretKeyInDB);
//read the data via repository
UserSecretKey loadedUserSecretKey = userSecretKeyRepository
.findFirstByClientId(clientId);
//this will be true because automatic decryption has happened
Assertions.assertEquals(plainTextSecret, loadedUserSecretKey.getHmacSecretKey());
}
|
5. Conclusion
We have seen how to use attribute converter to automatically encrypt and decrypt data stored in a database.
Beyond this use case, the attribute converter can also be used for other things like converting from one data type to another
and many others.
Although we used AES encryption in this article, the technique can be easily adapted for other encryption algorithms.
As we can see in the test in section 4, the automatic encryption and decryption is only applied when we use
a JPA Repository instance to persist and read the data we need.
The complete source code is available on GitHub.
If you find this tutorial helpful, don’t forget to check out our
other Spring Boot security articles. Share your thoughts or ask questions in the comments below!
Happy Coding