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_KEY environment variable
  • Development: An auto-generated key persisted in the local SQLite settings table when NODE_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 ciphertext
  • iv (TEXT): The hex-encoded 16-byte initialization vector
  • auth_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_KEY and refuses to use the default dummy value in production.
  • Auth tag enforcement: Setting AUTH_TAG_BYTES = 16 prevents 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_KEY environment variable (32 bytes decoded)
  • Storage: Separate encrypted_key, iv, and auth_tag columns in SQLite api_keys table
  • Implementation: Centralized in server/src/lib/crypto.ts with encrypt() and decrypt() 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:

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 →