How FreeLLMAPI Handles API Key Encryption: AES-256-GCM Implementation Explained
FreeLLMAPI encrypts all provider API keys using AES-256-GCM via the Node.js crypto module, storing the ciphertext, initialization vector, and authentication tag in an SQLite database while requiring a 64-character hex encryption key via the ENCRYPTION_KEY environment variable in production.
The open-source FreeLLMAPI repository (tashfeenahmed/freellmapi) implements a zero-trust approach to sensitive credential storage. Rather than storing plaintext keys, the system uses Node.js native cryptography to ensure that database breaches do not expose actual provider API tokens. The encryption layer is centralized in server/src/lib/crypto.ts and consumed by key management routes and service layers throughout the application.
Encryption Architecture Overview
FreeLLMAPI treats API keys as sensitive data requiring at-rest encryption before persistence. The architecture separates encryption logic from storage concerns, isolating all cryptographic operations in a dedicated utility module. This design allows the routing layer, media services, and embedding services to decrypt keys only when dispatching requests to external LLM providers.
The system uses AES-256-GCM (Galois/Counter Mode), which provides both confidentiality and authenticity. Each encrypted key generates a unique 128-bit initialization vector (IV) and a 128-bit authentication tag, preventing tampering and eliminating patterns that could aid cryptanalysis.
Key Initialization and Management
Encryption key material is handled differently depending on the runtime environment to balance security with developer experience.
Production Environment Setup
In production, FreeLLMAPI requires a cryptographically strong key provided via the ENCRYPTION_KEY environment variable. The system validates this key strictly before use:
// server/src/lib/crypto.ts
export function initEncryptionKey(db: Database.Database): void {
const envKey = process.env.ENCRYPTION_KEY;
if (envKey && envKey !== PLACEHOLDER_KEY) {
cachedKey = parseHexKey(envKey, 'env'); // validates 64-char hex format
return;
}
// development fallback logic...
}
The parseHexKey function enforces exactly 64 hexadecimal characters (32 bytes), aborting immediately if the format is invalid. The code also rejects placeholder values like your-64-char-hex-key-here to prevent accidental deployment with demo credentials.
Development Fallback
When NODE_ENV is not set to production, the system auto-generates a secure random key and persists it in the local SQLite settings table. This ephemeral key lasts for the lifetime of the local database clone, ensuring developers never commit production secrets while maintaining encryption functionality.
The Encryption Process
When users add a new provider key via endpoints like POST /keys or POST /keys/custom, the plaintext undergoes transformation through 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 three distinct outputs:
- encrypted: The hex-encoded ciphertext
- iv: The 16-byte initialization vector (hex-encoded)
- authTag: The 16-byte GCM authentication tag (hex-encoded)
These values are stored in the api_keys table columns encrypted_key, iv, and auth_tag respectively.
Decryption and Usage
Service layers retrieve provider credentials by fetching the stored encryption components and passing them to the decrypt() function:
// 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;
}
This decryption routine is invoked throughout the codebase. For example, in server/src/routes/keys.ts, the system decrypts keys to display masked versions:
// server/src/routes/keys.ts
const realKey = decrypt(row.encrypted_key, row.iv, row.auth_tag);
maskedKey = maskKey(realKey);
Similarly, server/src/services/router.ts, server/src/services/media.ts, and server/src/services/embeddings.ts decrypt keys immediately before dispatching requests to external APIs. If decryption fails due to corrupted data or key mismatches, the system returns "[decrypt failed]" rather than crashing.
Security Implementation Details
The implementation addresses several attack vectors through strict validation:
| Security Concern | Mitigation Strategy |
|---|---|
| Key length/format | parseHexKey enforces exactly 32 bytes (64 hex chars) with format validation |
| Placeholder keys | Explicit rejection of demo keys in production environments |
| Auth tag truncation | AUTH_TAG_BYTES = 16 forces full-size 128-bit tags, preventing 4-byte truncation attacks |
| Information leakage | Only masked keys (maskKey) return to clients; plaintext never leaves the server process |
| Database exposure | SQLite stores only ciphertext; the encryption key resides solely in memory or environment variables |
End-to-End Workflow
The complete lifecycle of API key encryption follows this sequence:
- Startup:
initEncryptionKey()validates the environment variable or generates a development key after the database connection opens inserver/src/app.ts - Storage: Adding a key triggers
encrypt(), which insertsencrypted_key,iv, andauth_taginto theapi_keystable viaserver/src/routes/keys.ts - Retrieval: Service layers query the database and call
decrypt()to obtain plaintext for SDK authentication - Rotation: Updating a key repeats the encryption process, while deletions remove the ciphertext row entirely
Code Examples
Adding a New Provider Key
import fetch from 'node-fetch';
await fetch('http://localhost:3000/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform: 'google',
key: 'sk-abcdef1234567890',
label: 'My Google key'
})
});
Internally, the route calls encrypt() and stores the resulting components in SQLite.
Retrieving Masked Keys
const res = await fetch('http://localhost:3000/keys');
const keys = await res.json();
console.log(keys);
/* Output:
[
{
id: 3,
platform: 'google',
label: 'My Google key',
maskedKey: 'sk-...7890',
enabled: true
}
]
*/
The server decrypts entries internally but only exposes masked versions via the API.
Using Stored Keys in Service Calls
import { getDb } from '../db/index.js';
import { decrypt } from '../lib/crypto.js';
function getProviderKey(platform: string): string {
const db = getDb();
const row = db.prepare(`
SELECT encrypted_key, iv, auth_tag
FROM api_keys
WHERE platform = ? AND enabled = 1
ORDER BY created_at DESC LIMIT 1
`).get(platform);
return decrypt(row.encrypted_key, row.iv, row.auth_tag);
}
// Usage
const apiKey = getProviderKey('google');
await fetch('https://generativelanguage.googleapis.com/v1/...', {
headers: { Authorization: `Bearer ${apiKey}` }
});
Summary
- FreeLLMAPI uses AES-256-GCM encryption via Node.js
cryptoto secure API keys at rest in SQLite - Encryption keys must be provided as 64-character hex strings via the
ENCRYPTION_KEYenvironment variable in production - The system stores three components per key:
encrypted_key,iv, andauth_tagin theapi_keystable - All cryptographic logic is isolated in
server/src/lib/crypto.ts, consumed by routes and services that need to decrypt credentials - Security features include placeholder key rejection, full-size authentication tags, and automatic key masking in API responses
Frequently Asked Questions
What encryption algorithm does FreeLLMAPI use for API keys?
FreeLLMAPI uses AES-256-GCM (Advanced Encryption Standard in Galois/Counter Mode). This algorithm provides both confidentiality and authentication, ensuring that tampered ciphertext is detected during decryption. The implementation uses Node.js's native crypto module with a 256-bit key, 128-bit IV, and 128-bit authentication tag.
How do I configure the encryption key for production deployment?
Set the ENCRYPTION_KEY environment variable to a 64-character hexadecimal string (representing 32 bytes) before starting the server. The application validates this key format during startup in initEncryptionKey() and will abort if the key is missing, improperly formatted, or set to the placeholder value. Development environments automatically generate keys, but production requires explicit configuration to prevent data loss.
Where exactly are the encrypted API keys stored?
Encrypted keys reside in the SQLite database in the api_keys table, specifically across three columns: encrypted_key (ciphertext), iv (initialization vector), and auth_tag (authentication tag). The encryption key itself is never stored in the database; it lives only in the server process memory (cached after initialization) or the environment variables.
Can I rotate the encryption key without losing existing API keys?
FreeLLMAPI does not currently implement automatic re-encryption for key rotation. To rotate the master encryption key, you would need to decrypt all existing entries using the old key and re-encrypt them with the new key. Because the application caches the encryption key at startup, rotating the environment variable requires a server restart to take effect, but existing encrypted data remains accessible only with the original key used during encryption.
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 →