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:

  1. Startup: initEncryptionKey() validates the environment variable or generates a development key after the database connection opens in server/src/app.ts
  2. Storage: Adding a key triggers encrypt(), which inserts encrypted_key, iv, and auth_tag into the api_keys table via server/src/routes/keys.ts
  3. Retrieval: Service layers query the database and call decrypt() to obtain plaintext for SDK authentication
  4. 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 crypto to secure API keys at rest in SQLite
  • Encryption keys must be provided as 64-character hex strings via the ENCRYPTION_KEY environment variable in production
  • The system stores three components per key: encrypted_key, iv, and auth_tag in the api_keys table
  • 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →