Skip to content

Encryption

Contract

@modularityjs/encryption defines the abstract EncryptionService:

typescript
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.

typescript
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:

OptionTypeDescription
keysEncryptionKeyEntry[]List of keys. First key is primary (used for encryption). All keys available for decryption. Required, at least one.

Each key entry:

FieldTypeDescription
idstringUnique identifier for this key. Must not contain :. Stored as prefix in ciphertext.
keystring64-character hex string (256 bits).

Generate a key:

bash
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

typescript
@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
typescript
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:

typescript
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 ID
  • decrypt() reads the key ID prefix from the ciphertext and uses the matching key
  • Existing data encrypted with k1 continues to decrypt as long as k1 is in the list

Rotation steps:

  1. Generate a new key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
  2. Add it as the first entry in keys, keep the old key(s)
  3. Deploy — new data encrypts with the new key, old data still decrypts
  4. (Optional) Re-encrypt existing data by reading and re-writing each record
  5. 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)