Encryption
Contract
@modularityjs/encryption defines the abstract EncryptionService:
abstract class EncryptionService {
abstract encrypt(plaintext: string): Promise<string>;
abstract decrypt(ciphertext: string): Promise<string>;
}Asynchronous interface — local drivers like AES-GCM complete in microseconds, but the contract stays Promise<string> so remote drivers (KMS, HSM, Vault) that need network round-trips can plug in without breaking callers. Input and output are strings (ciphertext is base64-encoded for safe storage in JSON, databases, and sessions).
Drivers
AES-256-GCM (@modularityjs/encryption-aes)
Uses Node.js built-in crypto — zero external dependencies.
import { EncryptionModule } from '@modularityjs/encryption';
import { EncryptionAesModule } from '@modularityjs/encryption-aes';
const modules = [
EncryptionModule,
EncryptionAesModule.forRoot({
keys: [{ id: 'k1', key: process.env.ENCRYPTION_KEY! }],
}),
];Configuration:
| Option | Type | Description |
|---|---|---|
keys | EncryptionKeyEntry[] | List of keys. First key is primary (used for encryption). All keys available for decryption. Required, at least one. |
Each key entry:
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier for this key. Must not contain :. Stored as prefix in ciphertext. |
key | string | 64-character hex string (256 bits). |
Generate a key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Ciphertext format
keyId:base64(iv[12] + authTag[16] + ciphertext)
The key ID prefix identifies which key encrypted the data. On decryption, the service extracts the prefix, looks up the matching key, and decrypts.
- IV: 12 random bytes (unique per encryption, prevents pattern analysis)
- Auth tag: 16 bytes (GCM authentication, detects tampering)
- Ciphertext: AES-256-GCM encrypted data
The output is self-contained — everything needed to decrypt is in the string (except the key). Safe to store in databases, session data, environment variables, or any string field.
Security properties
- Confidentiality: AES-256 encryption (NIST-approved, 256-bit key)
- Integrity: GCM authentication tag detects any modification to the ciphertext
- Uniqueness: Random IV per encryption — encrypting the same plaintext twice produces different ciphertext
- Tamper detection: Decrypting modified ciphertext throws an error (not silent corruption)
Usage
@Injectable()
class TokenStore {
constructor(
@Inject(EncryptionService) private readonly encryption: EncryptionService,
) {}
async store(refreshToken: string): Promise<string> {
return this.encryption.encrypt(refreshToken);
}
async retrieve(encrypted: string): Promise<string> {
return this.encryption.decrypt(encrypted);
}
}Common use cases
- Storing OIDC refresh tokens in session
- Encrypting API keys at rest in a database
- Protecting sensitive config values
- Encrypting PII before storage
Error handling
decrypt() throws when:
- The ciphertext was encrypted with a different key
- The ciphertext was tampered with (auth tag mismatch)
- The input is not valid base64 or is too short
try {
const plaintext = await encryption.decrypt(ciphertext);
} catch {
// Invalid or tampered ciphertext, or unknown key ID
}Key Rotation
To rotate encryption keys, add the new key as the first entry and keep old keys for decryption:
EncryptionAesModule.forRoot({
keys: [
{ id: 'k2', key: process.env.NEW_ENCRYPTION_KEY! }, // primary — encrypts new data
{ id: 'k1', key: process.env.OLD_ENCRYPTION_KEY! }, // old — decrypts existing data
],
});How it works:
encrypt()always uses the first key (k2) and prefixes ciphertext with its IDdecrypt()reads the key ID prefix from the ciphertext and uses the matching key- Existing data encrypted with
k1continues to decrypt as long ask1is in the list
Rotation steps:
- Generate a new key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" - Add it as the first entry in
keys, keep the old key(s) - Deploy — new data encrypts with the new key, old data still decrypts
- (Optional) Re-encrypt existing data by reading and re-writing each record
- Once all data is re-encrypted, remove the old key from the list
Key ID rules:
- Must be unique across all keys
- Must not contain
:(used as separator in ciphertext) - Stored as plaintext prefix in ciphertext — use opaque IDs (not the key itself)