| name | env-encryption |
| description | AES-256-GCM encryption and PBKDF2 key derivation patterns for secure secret storage in Node.js |
env-encryption skill
When to use
Use this skill when the user needs to:
- Understand the AES-256-GCM encryption scheme used by env-file-manager
- Implement similar encryption in their own code
- Verify the security properties of the vault format
- Troubleshoot decryption failures or auth tag errors
Overview
env-file-manager encrypts each variable value independently using AES-256-GCM. The master key is derived from a user passphrase using PBKDF2. The key is never stored - it exists only in memory for the duration of the vault session.
Encryption Flow
passphrase + random salt (32 bytes)
-> PBKDF2(iterations: 100000, hash: sha256, keylen: 32)
-> 256-bit master key (in memory only)
For each variable value:
random IV (12 bytes / 96 bits)
AES-256-GCM encrypt(plaintext, key, IV)
-> ciphertext
-> auth tag (16 bytes / 128 bits)
Stored in vault JSON:
{ iv: base64, ciphertext: base64, tag: base64 }
Key Derivation
PBKDF2 parameters:
- Algorithm: PBKDF2
- Hash: SHA-256
- Iterations: 100,000
- Output key length: 32 bytes (256 bits)
- Salt: 32 random bytes, stored in vault file (not secret)
The salt is generated once per vault and stored in the vault JSON file. It is not secret - its purpose is to prevent precomputation attacks.
The passphrase is never stored. Deriving the key from the same passphrase + salt always produces the same key.
Node.js Implementation Reference
Key derivation
import { pbkdf2Sync, randomBytes } from 'node:crypto';
function deriveKey(passphrase: string, salt: Buffer): Buffer {
return pbkdf2Sync(passphrase, salt, 100_000, 32, 'sha256');
}
function generateSalt(): Buffer {
return randomBytes(32);
}
Encryption
import { createCipheriv, randomBytes } from 'node:crypto';
function encrypt(plaintext: string, key: Buffer): {
iv: string;
ciphertext: string;
tag: string;
} {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return {
iv: iv.toString('base64'),
ciphertext: encrypted.toString('base64'),
tag: tag.toString('base64'),
};
}
Decryption
import { createDecipheriv } from 'node:crypto';
function decrypt(
{ iv, ciphertext, tag }: { iv: string; ciphertext: string; tag: string },
key: Buffer,
): string {
const decipher = createDecipheriv(
'aes-256-gcm',
key,
Buffer.from(iv, 'base64'),
);
decipher.setAuthTag(Buffer.from(tag, 'base64'));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(ciphertext, 'base64')),
decipher.final(),
]);
return decrypted.toString('utf8');
}
If the key is wrong or the ciphertext has been tampered with, decipher.final() throws with "Unsupported state or unable to authenticate data". Catch this error and return an appropriate message to the user - do not log the key or plaintext in the error handler.
Security Properties
| Property | Value |
|---|
| Cipher | AES-256-GCM |
| Key size | 256 bits |
| IV size | 96 bits (random per variable per write) |
| Auth tag | 128 bits |
| KDF | PBKDF2-SHA256 |
| KDF iterations | 100,000 |
| Salt size | 256 bits (random per vault, stored in vault file) |
Why AES-256-GCM
AES-GCM provides both confidentiality and authentication (AEAD). If a ciphertext is tampered with, decryption will fail with an auth tag mismatch before any plaintext is returned. This prevents padding oracle attacks and detects corruption.
Why PBKDF2
PBKDF2 with 100,000 iterations significantly increases the cost of brute-forcing the passphrase. A modern GPU running bcrypt at 100k iterations can attempt millions of passphrases per second with a simple symmetric cipher; PBKDF2 at this iteration count slows that to a manageable rate for strong passphrases.
For higher security requirements, consider increasing iterations to 600,000 (current NIST recommendation as of 2023) at the cost of slower unlock times.
What is NOT protected
- The vault salt (stored in plaintext - this is by design)
- Variable key names (stored as JSON keys - only values are encrypted)
- The number of variables in a vault (visible from the JSON structure)
Vault File Format Reference
{
"version": 1,
"project": "my-app",
"environment": "production",
"kdf": {
"algorithm": "pbkdf2",
"iterations": 100000,
"hash": "sha256",
"salt": "<base64 32 bytes>"
},
"variables": {
"DATABASE_URL": {
"iv": "<base64 12 bytes>",
"ciphertext": "<base64 N bytes>",
"tag": "<base64 16 bytes>",
"updated_at": "2026-01-15T12:00:00Z"
}
}
}
Troubleshooting
"Unable to authenticate data" on decrypt - Either the passphrase is wrong (key mismatch) or the vault file has been corrupted or tampered with. There is no bypass - the auth tag check is mandatory for security.
Different ciphertext for the same value - This is correct and expected. Each encryption call uses a new random IV, producing a different ciphertext even for identical plaintexts.
Slow unlock - PBKDF2 with 100,000 iterations takes roughly 100-300ms on modern hardware. This is intentional. If unlock speed is critical for your use case, reduce iterations - but this weakens brute-force resistance.
Vault file corruption - If the vault JSON file is corrupted, decryption will fail. Restore from the S3 backup copy with env-mgr pull.