How API Key Encryption Works in FreeLLMAPI: AES-256-GCM Implementation Guide
FreeLLMAPI encrypts provider API keys using AES-256-GCM via Node.js crypto, storing the ciphertext, IV, and authentication tag in SQLite while deriving the master key from the ENCRYPTION_KEY environment variable.
The tashfeenahmed/freellmapi project implements robust API key encryption to protect sensitive provider credentials at rest. Written in TypeScript for Node.js, the system isolates all cryptographic operations in server/src/lib/crypto.ts, ensuring that raw API keys never persist in plaintext and are only decrypted transiently in memory when routing requests to LLM providers.
Core Encryption Architecture
FreeLLMAPI uses symmetric encryption with the Node.js native crypto module. The design prioritizes authenticated encryption and strict key validation to prevent common implementation flaws.
Encryption Algorithm and Mode
The system employs AES-256-GCM (Advanced Encryption Standard with Galois/Counter Mode). This provides both confidentiality and authenticity, ensuring that tampered ciphertext is detected during decryption. The algorithm identifier is defined as aes-256-gcm in the source code.
Master Key Initialization
The encryption key originates from one of two sources:
- Production: A hex-encoded 64-character string (32 bytes) provided via the
ENCRYPTION_KEYenvironment variable - Development: An auto-generated key persisted in the local SQLite
settingstable whenNODE_ENV !== 'production'
In server/src/lib/crypto.ts, the initEncryptionKey() function validates the environment variable using parseHexKey(), which enforces exact length and hex formatting. The code explicitly rejects the placeholder value your-64-char-hex-key-here in production environments to prevent accidental deployment with dummy credentials.
Encryption Workflow in crypto.ts
All cryptographic operations are centralized in server/src/lib/crypto.ts, exposing three primary functions: initEncryptionKey(), encrypt(), and decrypt().
The encrypt() Function
When a user adds a new provider key via POST /keys, the plaintext is passed to the encrypt() function:
// server/src/lib/crypto.ts
export function encrypt(text: string): { encrypted: string; iv: string; authTag: string } {
const key = getEncryptionKey(); // cached key from initEncryptionKey()
const iv = crypto.randomBytes(16); // 128-bit nonce
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return { encrypted, iv: iv.toString('hex'), authTag };
}
The function generates a 16-byte random initialization vector (IV) and returns an object containing the hex-encoded ciphertext, IV, and 16-byte authentication tag. The constant AUTH_TAG_BYTES is set to 16 to prevent truncation attacks.
The decrypt() Function
Service layers retrieve stored keys using the decrypt() function, which requires the three components returned by encrypt():
// server/src/lib/crypto.ts
export function decrypt(encrypted: string, iv: string, authTag: string): string {
const key = getEncryptionKey();
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(iv, 'hex'),
{ authTagLength: AUTH_TAG_BYTES }
);
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
If decryption fails due to corruption or an invalid key, the calling code handles the error gracefully, often falling back to "[decrypt failed]" to prevent application crashes.
Database Storage Schema
Encrypted keys are stored in the SQLite api_keys table with the following schema relevant to encryption:
encrypted_key(TEXT): The hex-encoded AES-256-GCM ciphertextiv(TEXT): The hex-encoded 16-byte initialization vectorauth_tag(TEXT): The hex-encoded 16-byte GCM authentication tag
This separation ensures that no single column contains sufficient information to reconstruct the plaintext without access to the master encryption key.
Usage Patterns Across the Codebase
The encryption layer is consumed by both the HTTP API routes and internal service layers.
Route Layer Implementation
In server/src/routes/keys.ts, the creation endpoint encrypts before persistence:
// During POST /keys
const { encrypted, iv, authTag } = encrypt(apiKey);
// INSERT into api_keys (encrypted_key, iv, auth_tag, ...)
When listing keys, the route decrypts each entry and masks the result:
// server/src/routes/keys.ts
const realKey = decrypt(row.encrypted_key, row.iv, row.auth_tag);
const maskedKey = maskKey(realKey); // Returns "sk-...abcd" format
// Only maskedKey is sent to the client
Service Layer Decryption
Internal services decrypt keys transiently for provider SDK calls. For example, in server/src/services/router.ts (around line 585), the media service, embeddings service, and health check service all follow this pattern:
import { decrypt } from '../lib/crypto.js';
function getProviderKey(platform: string) {
const row = db.prepare(`
SELECT encrypted_key, iv, auth_tag
FROM api_keys
WHERE platform = ? AND enabled = 1
`).get(platform);
return decrypt(row.encrypted_key, row.iv, row.auth_tag);
}
The plaintext key exists only in memory during the request lifecycle and is never logged or cached.
Security Considerations and Mitigations
FreeLLMAPI implements several defenses against common cryptographic failures:
- Strict key validation: The
parseHexKey()function enforces exactly 64 hex characters (32 bytes), aborting on malformed keys. - Placeholder protection: The code compares against
PLACEHOLDER_KEYand refuses to use the default dummy value in production. - Auth tag enforcement: Setting
AUTH_TAG_BYTES = 16prevents truncation attacks that could reduce authentication strength. - Memory-only plaintext: Only masked keys (via
maskKey()) leave the server process; decrypted values are garbage-collected after use. - Dev isolation: Auto-generated development keys are stored in the local database file, ensuring they cannot be mistakenly deployed.
Summary
- Algorithm: AES-256-GCM authenticated encryption using Node.js
crypto - Key source: 64-character hex
ENCRYPTION_KEYenvironment variable (32 bytes decoded) - Storage: Separate
encrypted_key,iv, andauth_tagcolumns in SQLiteapi_keystable - Implementation: Centralized in
server/src/lib/crypto.tswithencrypt()anddecrypt()functions - Security: Enforces full 16-byte auth tags, rejects placeholder keys, and only exposes masked key representations via the API
Frequently Asked Questions
What encryption algorithm does FreeLLMAPI use for API keys?
FreeLLMAPI uses AES-256-GCM (Advanced Encryption Standard in Galois/Counter Mode) as implemented in server/src/lib/crypto.ts. This provides authenticated encryption, ensuring both confidentiality and integrity of the stored API keys.
How long must the ENCRYPTION_KEY be?
The ENCRYPTION_KEY environment variable must contain exactly 64 hexadecimal characters, which decode to 32 bytes (256 bits). The parseHexKey() validation function rejects keys of any other length or format.
Where are the encrypted API keys stored?
Encrypted keys are stored in the SQLite database table api_keys with three distinct columns: encrypted_key (ciphertext), iv (initialization vector), and auth_tag (GCM authentication tag). The plaintext is never written to disk.
What happens if decryption fails?
If the decrypt() function encounters invalid data or an incorrect master key, it throws an error that calling code typically catches. For example, in server/src/routes/keys.ts, failed decryptions result in a masked key value of "[decrypt failed]" being returned to the client, preventing application crashes while alerting administrators to data integrity issues.
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 →