diff --git a/services/comm-services-lib/Cargo.lock b/services/comm-services-lib/Cargo.lock
--- a/services/comm-services-lib/Cargo.lock
+++ b/services/comm-services-lib/Cargo.lock
@@ -266,6 +266,42 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
 
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "bytes",
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "209b47e8954a928e1d72e86eca7000ebb6655fe1436d33eefc2201cad027e237"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
 [[package]]
 name = "ahash"
 version = "0.7.6"
@@ -786,6 +822,16 @@
  "winapi",
 ]
 
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
 [[package]]
 name = "codespan-reporting"
 version = "0.11.1"
@@ -804,6 +850,8 @@
  "actix-multipart",
  "actix-web",
  "actix-web-httpauth",
+ "aead",
+ "aes-gcm",
  "anyhow",
  "aws-config",
  "aws-sdk-dynamodb",
@@ -882,9 +930,19 @@
 checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
 dependencies = [
  "generic-array",
+ "rand_core",
  "typenum",
 ]
 
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
 [[package]]
 name = "cxx"
 version = "1.0.91"
@@ -1155,6 +1213,16 @@
  "wasi 0.11.0+wasi-snapshot-preview1",
 ]
 
+[[package]]
+name = "ghash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
 [[package]]
 name = "gimli"
 version = "0.28.0"
@@ -1343,6 +1411,15 @@
  "hashbrown",
 ]
 
+[[package]]
+name = "inout"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
+dependencies = [
+ "generic-array",
+]
+
 [[package]]
 name = "instant"
 version = "0.1.12"
@@ -1568,6 +1645,12 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
 
+[[package]]
+name = "opaque-debug"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
+
 [[package]]
 name = "openssl"
 version = "0.10.56"
@@ -1697,6 +1780,18 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
 
+[[package]]
+name = "polyval"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
 [[package]]
 name = "ppv-lite86"
 version = "0.2.17"
@@ -2365,6 +2460,16 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
 
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
 [[package]]
 name = "untrusted"
 version = "0.7.1"
diff --git a/services/comm-services-lib/Cargo.toml b/services/comm-services-lib/Cargo.toml
--- a/services/comm-services-lib/Cargo.toml
+++ b/services/comm-services-lib/Cargo.toml
@@ -21,6 +21,7 @@
   "dep:tokio-stream",
   "dep:actix-web-httpauth",
 ]
+crypto = ["dep:aead", "dep:aes-gcm", "dep:bytes"]
 
 [dependencies]
 serde = { version = "1.0", features = ["derive"] }
@@ -51,3 +52,6 @@
 actix-web-httpauth = { version = "0.8.0", optional = true }
 actix-multipart = { version = "0.6", optional = true }
 tokio-stream = { version = "0.1.14", optional = true }
+# crypto dependencies
+aes-gcm = { version = "0.10", optional = true }
+aead = { version = "0.5", features = ["bytes"], optional = true }
diff --git a/services/comm-services-lib/src/crypto/aes256.rs b/services/comm-services-lib/src/crypto/aes256.rs
new file mode 100644
--- /dev/null
+++ b/services/comm-services-lib/src/crypto/aes256.rs
@@ -0,0 +1,122 @@
+use aead::{
+  generic_array::GenericArray, Aead, AeadCore, AeadInPlace, KeyInit, OsRng,
+};
+use aes_gcm::Aes256Gcm;
+use aws_sdk_dynamodb::types::AttributeValue;
+use bytes::BytesMut;
+
+use crate::database::{DBItemError, TryFromAttribute};
+
+pub use aes_gcm::Error as AES256Error;
+
+const TAG_LEN: usize = 16;
+const NONCE_LEN: usize = 12;
+
+#[derive(Clone, Debug, derive_more::From, derive_more::AsRef)]
+pub struct EncryptionKey(aes_gcm::Key<Aes256Gcm>);
+
+impl EncryptionKey {
+  /// Generates a new AES256 key.
+  pub fn new() -> EncryptionKey {
+    Aes256Gcm::generate_key(&mut OsRng).into()
+  }
+}
+
+impl Default for EncryptionKey {
+  fn default() -> Self {
+    Self::new()
+  }
+}
+
+// database conversions
+impl From<EncryptionKey> for AttributeValue {
+  fn from(key: EncryptionKey) -> Self {
+    use aws_sdk_dynamodb::primitives::Blob;
+    AttributeValue::B(Blob::new(key.0.to_vec()))
+  }
+}
+impl TryFromAttribute for EncryptionKey {
+  fn try_from_attr(
+    attribute_name: impl Into<String>,
+    attribute: Option<AttributeValue>,
+  ) -> Result<Self, DBItemError> {
+    let bytes = Vec::<u8>::try_from_attr(attribute_name, attribute)?;
+    let key = aes_gcm::Key::<Aes256Gcm>::from_slice(&bytes);
+    Ok(Self(*key))
+  }
+}
+
+/// Encrypts a plaintext with the given key. Returns the sealed data
+/// in the following format: nonce || ciphertext || tag
+pub fn encrypt(
+  plaintext: &[u8],
+  key: &EncryptionKey,
+) -> Result<Vec<u8>, AES256Error> {
+  let cipher = Aes256Gcm::new(key.as_ref());
+  let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
+
+  // Construct an output buffer: nonce || ciphertext || tag
+  // Then split it into nonce and ciphertext || tag,
+  // encrypt and concatenate again.
+  // This is done on contiguous memory to avoid extra allocations.
+
+  let mut output =
+    BytesMut::with_capacity(nonce.len() + plaintext.len() + TAG_LEN);
+  output.extend_from_slice(&nonce);
+  output.extend_from_slice(plaintext);
+
+  let mut buffer = output.split_off(nonce.len());
+  cipher.encrypt_in_place(&nonce, b"", &mut buffer)?;
+  output.unsplit(buffer);
+
+  Ok(output.into())
+}
+
+/// Decrypts a ciphertext with the given key. Expects the sealed data
+/// in the following format: nonce || ciphertext || tag
+pub fn decrypt(
+  ciphertext: &[u8],
+  key: &EncryptionKey,
+) -> Result<Vec<u8>, AES256Error> {
+  if ciphertext.len() < NONCE_LEN + TAG_LEN {
+    return Err(AES256Error);
+  }
+  let cipher = Aes256Gcm::new(key.as_ref());
+  let nonce = GenericArray::from_slice(&ciphertext[..NONCE_LEN]);
+  cipher.decrypt(nonce, &ciphertext[NONCE_LEN..])
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn test_aes256() {
+    let key = EncryptionKey::new();
+    let plaintext = b"hello world";
+    let ciphertext = encrypt(plaintext, &key).expect("Encrypt failed");
+    let decrypted = decrypt(&ciphertext, &key).expect("Decrypt failed");
+    assert_eq!(plaintext, &decrypted[..]);
+  }
+
+  #[test]
+  fn test_aes256_invalid_key() {
+    let key = EncryptionKey::new();
+    let plaintext = b"hello world";
+    let ciphertext = encrypt(plaintext, &key).expect("Encrypt failed");
+
+    let other_key = EncryptionKey::new();
+    decrypt(&ciphertext, &other_key).expect_err("Decrypt should fail");
+  }
+
+  #[test]
+  fn test_aes256_malfolmed() {
+    let key = EncryptionKey::new();
+    let plaintext = b"hello world";
+    let mut ciphertext = encrypt(plaintext, &key).expect("Encrypt failed");
+
+    ciphertext[0] ^= 0xaa; // do the flip
+
+    decrypt(&ciphertext, &key).expect_err("Decrypt should fail");
+  }
+}
diff --git a/services/comm-services-lib/src/crypto/mod.rs b/services/comm-services-lib/src/crypto/mod.rs
new file mode 100644
--- /dev/null
+++ b/services/comm-services-lib/src/crypto/mod.rs
@@ -0,0 +1,2 @@
+/// AES 256 GCM encryption and decryption.
+pub mod aes256;
diff --git a/services/comm-services-lib/src/lib.rs b/services/comm-services-lib/src/lib.rs
--- a/services/comm-services-lib/src/lib.rs
+++ b/services/comm-services-lib/src/lib.rs
@@ -2,6 +2,8 @@
 pub mod backup;
 pub mod blob;
 pub mod constants;
+#[cfg(feature = "crypto")]
+pub mod crypto;
 pub mod database;
 #[cfg(feature = "http")]
 pub mod http;