How Encrypted Key Storage with AES-256-GCM Works in FreeLLMAPI
FreeLLMAPI stores all provider API keys using AES-256-GCM encryption, splitting each secret into ciphertext, IV, and authentication tag across three separate database columns to ensure confidentiality at rest.
FreeLLMAPI is an open‑source LLM proxy router that must safely persist sensitive provider credentials. Rather than storing raw keys, the codebase implements a hardened encryption layer in server/src/lib/crypto.ts that leverages Node.js native crypto with AES‑256‑GCM. This design guarantees that even if the SQLite database file is exfiltrated, the keys remain inaccessible without the 32‑byte master key.
Master Key Initialization
The encryption lifecycle begins with initEncryptionKey(), defined in [server/src/lib/crypto.ts](https://github.com/tashfeenahmed/freellmapi/blob/main/server/src/lib/crypto.ts). This function resolves the master key through a strict hierarchy:
- Production Environment – When the
ENCRYPTION_KEYenvironment variable is present, the code validates it as exactly 64 hex characters (32 bytes) viaparseHexKey()and loads it directly into memory. - Development Fallback – In non‑production builds, if the env var is missing, the system looks for a file named
.encryption-keyadjacent to the SQLite database. If absent, it generates a fresh random key, writes it atomically to that file, and caches it for the process lifetime. - Legacy/Test Path – For in‑memory or legacy databases, the key may be read from the deprecated
settingstable, though this is reserved strictly for test suites.
Once loaded, the master key never touches disk again (unless the dev‑fallback file is created during first run). encryptionKeyFingerprint() derives a public SHA‑256 identifier (first 16 hex chars) from the key for logging and diagnostics without revealing the secret itself.
The Encryption Flow
When a user saves a new provider key, the encrypt(text) function (also in [crypto.ts](https://github.com/tashfeenahmed/freellmapi/blob/main/server/src/lib/crypto.ts)) executes the following steps:
- Generates a cryptographically random 16‑byte initialization vector (IV) using
crypto.randomBytes(16). - Creates a
Cipherinstance viacrypto.createCipheriv('aes-256-gcm', key, iv). - Streams the plaintext through the cipher to produce hex‑encoded ciphertext.
- Extracts the 16‑byte authentication tag from
cipher.getAuthTag().
The function returns an object containing { encrypted, iv, authTag }, which the persistence layer then writes to the api_keys table.
Database Storage Schema
The api_keys table stores encrypted secrets in three distinct columns to prevent concatenation attacks and enable strict validation:
key_encrypted– The hex‑encoded AES‑256‑GCM ciphertext.key_iv– The 16‑byte IV used for that specific encryption operation.key_auth_tag– The 16‑byte GCM authentication tag.
The same triplet pattern applies to optional per‑key proxy URLs (proxy_encrypted, proxy_iv, proxy_auth_tag) handled in [server/src/lib/key-proxy.ts](https://github.com/tashfeenahmed/freellmapi/blob/main/server/src/lib/key-proxy.ts). This columnar separation ensures that an attacker who can modify only one field will trigger an integrity failure during decryption.
Decryption and Verification
When the router needs a credential for an outgoing request, it invokes decrypt(encrypted, iv, authTag) from [crypto.ts](https://github.com/tashfeenahmed/freellmapi/blob/main/server/src/lib/crypto.ts):
- Reconstructs a
Decipherusing the master key, provided IV, and explicitly setsauthTagLength: 16. - Calls
decipher.setAuthTag()with the stored 16‑byte tag before any data is decrypted. - Returns the original plaintext; if the tag verification fails (indicating tampering or a mismatched master key), the operation throws.
All decryption calls in [server/src/services/router.ts](https://github.com/tashfeenahmed/freellmapi/blob/main/server/src/services/router.ts) are wrapped in try/catch blocks. If a row cannot be decrypted—typically because the database was migrated to a host with a different ENCRYPTION_KEY—the router gracefully falls back to the global proxy configuration rather than crashing.
Proxy URL Encryption and Masking
Per‑key proxy credentials receive identical protection via [key-proxy.ts](https://github.com/tashfeenahmed/freellmapi/blob/main/server/src/lib/key-proxy.ts). The module exports:
encryptProxyUrl()– Wraps the URL string through the same AES‑256‑GCM pipeline.decryptProxyUrl()– Reverses the process; returns an empty string on failure to avoid leaking stack traces.maskProxyUrl()– Displays safe previews such assocks5://alice:***@proxy.internal:1080in the UI, ensuring the plaintext secret is never echoed to the browser.
maskKey() provides the same redaction service for API keys, showing only the last four characters in administrative views.
Summary
- AES‑256‑GCM provides authenticated encryption for all provider credentials in FreeLLMAPI.
- The master key is sourced from the
ENCRYPTION_KEYenvironment variable or a.encryption-keydev‑fallback file, validated as 64 hex characters. - Ciphertext, IV, and auth tags are stored in separate SQLite columns (
key_encrypted,key_iv,key_auth_tag). - Decryption enforces a fixed 16‑byte auth tag length to prevent truncation attacks.
- Failures during decryption are handled gracefully, falling back to global settings rather than exposing errors.
- UI masking utilities prevent accidental disclosure of keys in logs or frontend interfaces.
Frequently Asked Questions
What happens if I lose the ENCRYPTION_KEY environment variable?
If the master key is lost, all previously encrypted rows become permanently undecryptable because the system relies solely on that 32‑byte key for AES‑256‑GCM decryption. The application will continue to run but will treat stored keys as unusable, falling back to global proxy settings or prompting for re‑entry. Always back up the ENCRYPTION_KEY or the .encryption-key file created during development.
Why does FreeLLMAPI use AES‑256‑GCM instead of AES-256-CBC?
AES‑256‑GCM provides both confidentiality and authenticity, whereas CBC mode only offers confidentiality. The 16‑byte authentication tag generated during encryption ensures that any tampering with the ciphertext or IV is detected during decryption. Additionally, GCM is a streaming mode that works efficiently with Node.js createCipheriv, eliminating the need for manual padding schemes.
How is the master key protected during development?
In non‑production builds, initEncryptionKey() generates a random 32‑byte key if one does not exist and writes it atomically to a .encryption-key file next to the database. This file has restrictive permissions (when the OS supports it) and is loaded into memory once at startup. The key is never logged; only a SHA‑256 fingerprint is displayed in diagnostics.
Can I rotate the encryption key without losing data?
The codebase does not currently implement automatic re‑encryption. To rotate the master key, you must decrypt all existing rows using the old key, update the ENCRYPTION_KEY environment variable, and re‑insert the plaintext values so they are encrypted with the new master key. Because the decryption flow in router.ts falls back gracefully on failure, you can perform this migration incrementally without downtime.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →