AI Business Maturity Model
Certifications
Find a CoachFind a SpeakerSign In

Storing Secrets in the Database

How to store API keys and service credentials in PostgreSQL when environment variables aren't an option — with an honest security review of the trade-offs.

The Problem

Many deployment platforms (Docker, CI/CD pipelines, managed hosting) make it difficult or impossible to update environment variables without a full redeploy. When your application needs API keys for third-party services — OpenAI, Stripe, SendGrid, etc. — you need a way to:

  • Add, update, or rotate keys without redeploying
  • Let administrators manage keys through a web interface
  • Fall back to environment variables for local development
  • Cache values to avoid a database round-trip on every API call

The solution: store settings and secrets in a database table, expose them through a settings library, and protect them with proper access controls.

Security Review

Approach A: Plain-Text Storage (Simpler, Less Secure)

Storing secrets as plain text in a database column, relying on database access controls and TLS for protection. This is the simplest approach and is common in early-stage projects.

Risks

  • Database breach — attacker gets every secret immediately, plain text, no additional work needed

  • Backup exposure — database backups (pg_dump, automated snapshots) contain all secrets in plain text

  • Admin over-access — any database admin, DBA, or ops engineer can read every API key

  • Query logging — ORM debug logging, slow query logs, or pg_stat_statements could capture secret values

  • No audit trail — no record of who read or changed a secret (without additional logging)

Mitigations Already In Place

  • TLS in transit — all database connections use SSL, secrets are never sent over the wire in plain text

  • API auth — settings endpoints require an authenticated admin session

  • UI masking — secret values are masked in the admin interface (first 8 + last 4 characters)

  • Env fallback — local dev uses .env files (never committed to git), so secrets don't need to be in the DB for development

Approach B: Application-Layer Encryption (Recommended)

Encrypt secret values before storing them in the database using AES-256-GCM. The encryption master key lives in an environment variable — the one secret you do need to manage through your deployment platform. This gives you defense in depth: even if the database is compromised, the secrets are encrypted.

lib/crypto.ts — Application-layer encryption

TypeScript
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;

function getMasterKey(): Buffer {
  const key = process.env.SETTINGS_ENCRYPTION_KEY;
  if (!key) throw new Error('SETTINGS_ENCRYPTION_KEY environment variable is required');
  // Key must be 32 bytes (256 bits). Use a hex-encoded string in the env var.
  return Buffer.from(key, 'hex');
}

export function encrypt(plainText: string): string {
  const iv = randomBytes(IV_LENGTH);
  const cipher = createCipheriv(ALGORITHM, getMasterKey(), iv);
  const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag();
  // Store as: iv:authTag:ciphertext (all hex-encoded)
  return [
    iv.toString('hex'),
    authTag.toString('hex'),
    encrypted.toString('hex'),
  ].join(':');
}

export function decrypt(stored: string): string {
  const [ivHex, authTagHex, encryptedHex] = stored.split(':');
  const iv = Buffer.from(ivHex, 'hex');
  const authTag = Buffer.from(authTagHex, 'hex');
  const encrypted = Buffer.from(encryptedHex, 'hex');
  const decipher = createDecipheriv(ALGORITHM, getMasterKey(), iv);
  decipher.setAuthTag(authTag);
  return decipher.update(encrypted) + decipher.final('utf8');
}

Generate a 256-bit encryption key

bash
# Run once, store the output in your deployment platform's env vars
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Output: e.g. a1b2c3d4e5f6...  (64 hex characters = 32 bytes = 256 bits)

# Add to your environment:
SETTINGS_ENCRYPTION_KEY=a1b2c3d4e5f6...

Security Comparison

ThreatPlain TextApp-Layer EncryptionExternal Vault
Database SQL injectionAll secrets exposedEncrypted blobs onlyNo secrets in DB
Backup file stolenAll secrets readableEncrypted, useless without keyNo secrets in backup
Rogue database adminCan read everythingSees only ciphertextNo access to vault
Application server compromisedAccess via app codeAccess via app code + key in memoryAccess via app code + vault token
Setup complexityMinimalOne env var (master key)Separate infrastructure
Key rotation difficultyUpdate DB rowRe-encrypt all rows + update envVault handles rotation

1. The Database Schema

A single table stores all application settings. Secret values are encrypted before storage. The is_secret flag controls UI masking — it does not affect storage.

Migration: CreateAppSettingsTable

SQL
CREATE TABLE IF NOT EXISTS app_settings (
  key           VARCHAR(255) PRIMARY KEY,
  value         TEXT NOT NULL,
  is_secret     BOOLEAN NOT NULL DEFAULT false,
  description   TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Index for fast lookups (primary key already covers this, but explicit for clarity)
-- The primary key on "key" is sufficient for single-row lookups.

COMMENT ON TABLE app_settings IS 'Application settings and encrypted secrets';
COMMENT ON COLUMN app_settings.value IS 'Plain text for non-secrets, AES-256-GCM encrypted for secrets';
COMMENT ON COLUMN app_settings.is_secret IS 'Controls UI masking. Encrypted values should always have this set to true';

For TypeORM projects, the equivalent entity:

entities/AppSetting.ts — TypeORM entity

TypeScript
import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';

@Entity('app_settings')
export class AppSetting {
  @PrimaryColumn({ type: 'varchar', length: 255 })
  key!: string;

  @Column({ type: 'text' })
  value!: string;

  @Column({ name: 'is_secret', type: 'boolean', default: false })
  isSecret!: boolean;

  @Column({ type: 'text', nullable: true })
  description?: string;

  @CreateDateColumn({ name: 'created_at' })
  createdAt!: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt!: Date;
}
ColumnPurpose
keyThe setting name, e.g. OPENROUTER_API_KEY. Primary key — must be unique.
valueThe value. For secrets: AES-256-GCM ciphertext (iv:authTag:encrypted). For non-secrets: plain text.
is_secretBoolean flag. Controls UI masking and whether the value is encrypted before storage.
descriptionHuman-readable description shown in the admin settings UI.
created_at / updated_atTimestamps for auditing when settings were created or last modified.

2. The Settings Library

The core module that all server-side code uses. Three functions: getSetting(), setSetting(), and listSettings(). Values are cached in memory with a 60-second TTL to avoid a database round-trip on every API call.

lib/settings.ts — Core settings module

TypeScript
import { encrypt, decrypt } from './crypto';

const CACHE_TTL_MS = 60_000; // 60 seconds

interface CacheEntry {
  value: string | null;
  expiresAt: number;
}

const cache = new Map<string, CacheEntry>();

/**
 * Retrieve a setting value by key.
 * Resolution order: cache → database → environment variable fallback.
 */
async function getSetting(key: string): Promise<string | null> {
  const now = Date.now();

  const cached = cache.get(key);
  if (cached && cached.expiresAt > now) {
    return cached.value;
  }

  try {
    const repo = await getSettingsRepository();
    const row = await repo.findOne({ where: { key } });

    if (row?.value) {
      const plainValue = row.isSecret ? decrypt(row.value) : row.value;
      cache.set(key, { value: plainValue, expiresAt: now + CACHE_TTL_MS });
      return plainValue;
    }
  } catch {
    // Database unavailable — fall through to env var
  }

  // Fallback: environment variable (useful for local dev)
  const envVal = process.env[key] ?? null;
  if (envVal) {
    cache.set(key, { value: envVal, expiresAt: now + CACHE_TTL_MS });
  }
  return envVal;
}

/**
 * Store or update a setting. Secret values are encrypted before storage.
 */
async function setSetting(
  key: string,
  value: string,
  options?: { isSecret?: boolean; description?: string }
): Promise<void> {
  const repo = await getSettingsRepository();
  const storedValue = options?.isSecret ? encrypt(value) : value;

  await repo.upsert(
    {
      key,
      value: storedValue,
      isSecret: options?.isSecret ?? false,
      description: options?.description,
    },
    ['key']
  );

  // Invalidate cache so next read picks up the new value
  cache.delete(key);
}

/**
 * List all settings. Secret values remain encrypted (caller must mask for UI).
 */
async function listSettings() {
  const repo = await getSettingsRepository();
  return repo.find({ order: { key: 'ASC' } });
}

// Helper to get the TypeORM repository
async function getSettingsRepository() {
  const { getDataSource } = await import('./db/init');
  const { AppSetting } = await import('./db/entities/AppSetting');
  const ds = getDataSource();
  return ds.getRepository(AppSetting);
}

3. The API Layer

Two REST endpoints, both requiring an authenticated admin session. Secret values are masked in GET responses — the full value is never sent to the browser after initial storage.

app/api/settings/route.ts

TypeScript
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { listSettings, setSetting } from '@/lib/settings';
import { UserRole } from '@/lib/db/entities/User';

// GET /api/settings — List all settings (secrets are masked)
async function GET() {
  const session = await auth();
  if (!session?.user || session.user.role !== UserRole.ADMIN) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
  }

  const settings = await listSettings();
  const masked = settings.map((s) => ({
    ...s,
    value: s.isSecret && s.value
      ? s.value.slice(0, 8) + '••••••••' + s.value.slice(-4)
      : s.value,
  }));

  return NextResponse.json({ data: masked });
}

// POST /api/settings — Create or update a setting
async function POST(request: Request) {
  const session = await auth();
  if (!session?.user || session.user.role !== UserRole.ADMIN) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
  }

  const body = await request.json();
  // Validate with Zod in production (omitted here for clarity)
  const { key, value, isSecret, description } = body;

  if (!key || !value) {
    return NextResponse.json({ error: 'key and value are required' }, { status: 400 });
  }

  await setSetting(key, value, { isSecret, description });
  return NextResponse.json({ data: { key, saved: true } });
}

4. How Code Consumes a Key

Any server-side module calls getSetting(). The caller doesn't need to know whether the value came from the database, cache, or an environment variable.

Example: Using a stored API key

TypeScript
import { getSetting } from '@/lib/settings';

async function callOpenRouter(prompt: string) {
  const apiKey = await getSetting('OPENROUTER_API_KEY');
  if (!apiKey) {
    throw new Error('OPENROUTER_API_KEY is not configured');
  }

  const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'anthropic/claude-sonnet-4',
      messages: [{ role: 'user', content: prompt }],
    }),
  });

  return response.json();
}

For local development, if you don't want to set up the database row, just add the key to your .env file. The getSetting() function falls back to process.env automatically:

.env (local development only)

bash
# Local dev fallback — never committed to git
OPENROUTER_API_KEY=sk-or-v1-abc123...

5. Adding a New Secret — Step by Step

Here's what a developer does to add a new API key (e.g., STRIPE_SECRET_KEY):

Option A

Via the Admin Settings UI

  1. Log in as an admin user

  2. Navigate to the settings page (e.g., /god/settings or /dashboard/settings)

  3. Click "Add Setting"

  4. Enter key name: STRIPE_SECRET_KEY

  5. Enter the value: sk_live_...

  6. Check "Mark as secret" (enables encryption + UI masking)

  7. Click Save — the value is encrypted and stored

Option B

Via the API

  1. Authenticate as admin to get a session cookie

  2. POST to /api/settings with JSON body: { "key": "STRIPE_SECRET_KEY", "value": "sk_live_...", "isSecret": true }

  3. The value is encrypted server-side before storage

Option C

Via SQL (emergency only)

  1. Connect to the database directly

  2. You must encrypt the value yourself or insert plain text and re-encrypt via the API

  3. INSERT INTO app_settings (key, value, is_secret) VALUES ('STRIPE_SECRET_KEY', 'sk_live_...', true)

  4. Warning: If using app-layer encryption, this stores it unencrypted — use the API instead

Then in your code, retrieve it with one line:

const stripeKey = await getSetting('STRIPE_SECRET_KEY');

6. Security Hardening Checklist

Whether you choose plain-text or encrypted storage, apply these hardening measures:

Database Access

  • Use a dedicated database user for the application with minimal permissions (SELECT, INSERT, UPDATE on app_settings only)

  • Disable direct database access from the public internet — use private networking or SSH tunnels

  • Enable SSL/TLS for all database connections (reject unencrypted connections)

  • If your database supports it, enable Row Level Security (RLS) or column-level permissions

Application Layer

  • Restrict settings API to ADMIN role only — never just "authenticated"

  • Validate input with Zod schemas (key format, value length limits)

  • Rate-limit the settings API endpoints to prevent brute-force enumeration

  • Never log secret values — log only the key name and operation (set, read, deleted)

Encryption & Keys

  • Use AES-256-GCM (authenticated encryption) — not AES-CBC (vulnerable to padding oracle attacks)

  • Generate a unique IV (initialization vector) for every encryption operation — never reuse IVs

  • Store the master encryption key in an environment variable, not in the database or code

  • Plan for key rotation: write a migration script that re-encrypts all rows with a new master key

Monitoring & Audit

  • Log all settings API access (who read/wrote what, when) to a separate audit log

  • Alert on unusual patterns: bulk reads, reads from new IP addresses, settings changes outside business hours

  • Regularly review who has ADMIN access — principle of least privilege

  • Include settings table in your security review checklist (see Security Review resource)

7. Security Model Summary

LayerProtection
DatabaseMinimal permissions, private networking, SSL/TLS connections, optional RLS
At RestAES-256-GCM encryption (Approach B) — or plain text (Approach A) with access controls
In TransitTLS for database connections, HTTPS for API endpoints
APIRequires authenticated ADMIN session, rate-limited, input validated with Zod
UISecret values masked (first 8 + last 4 chars visible), full values never sent after initial save
Cache60-second in-memory TTL, invalidated on write, per-instance only
Local DevFalls back to .env file (never committed to git via .gitignore)
AuditAPI access logging, alert on unusual access patterns

How to Build This — AI Guide

AI Build Guide — Step by Step

text
STEP 1: CREATE THE ENCRYPTION MODULE
  Create lib/crypto.ts with encrypt() and decrypt() functions.
  Use AES-256-GCM with random IVs.
  Master key comes from SETTINGS_ENCRYPTION_KEY env var.
  Store as iv:authTag:ciphertext (hex-encoded, colon-separated).
  Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

STEP 2: CREATE THE DATABASE TABLE/ENTITY
  For TypeORM: Create entities/AppSetting.ts
    - key (VARCHAR 255, primary key)
    - value (TEXT, stores encrypted or plain text)
    - isSecret (BOOLEAN, default false)
    - description (TEXT, nullable)
    - createdAt, updatedAt (timestamps)
  Register the entity in your TypeORM config.
  Generate and run a migration.

STEP 3: CREATE THE SETTINGS LIBRARY
  Create lib/settings.ts with three functions:
    - getSetting(key): cache → database (decrypt if secret) → process.env fallback
    - setSetting(key, value, options): encrypt if secret → upsert → invalidate cache
    - listSettings(): return all rows (values stay encrypted for secrets)
  Use a Map<string, CacheEntry> with 60-second TTL.

STEP 4: CREATE THE API ROUTES
  Create app/api/settings/route.ts:
    GET  — requires ADMIN role, returns all settings with secrets masked
    POST — requires ADMIN role, accepts { key, value, isSecret, description }
  Mask format: first 8 chars + "••••••••" + last 4 chars
  Validate input with Zod schema.

STEP 5: CREATE THE ADMIN UI (OPTIONAL)
  Build a settings management page (e.g., /god/settings):
    - Table listing all settings
    - Add/edit form with "is_secret" checkbox
    - Secret values shown masked
    - Only accessible to admin users

STEP 6: ADD THE ENCRYPTION KEY TO YOUR ENVIRONMENT
  Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
  Add SETTINGS_ENCRYPTION_KEY to:
    - Your deployment platform's environment/secrets
    - Your .env file for local development
  This is the ONE env var you must manage through your platform.

STEP 7: MIGRATE EXISTING ENV VARS (OPTIONAL)
  For each API key you want to move from env vars to the database:
    1. Call setSetting('KEY_NAME', process.env.KEY_NAME, { isSecret: true })
    2. Update consuming code to use: await getSetting('KEY_NAME')
    3. The env var fallback means both approaches work during migration

ADAPTATION CHECKLIST
  □ crypto.ts created with AES-256-GCM encrypt/decrypt
  □ AppSetting entity created and registered
  □ Migration generated and run
  □ settings.ts created with getSetting/setSetting/listSettings
  □ API routes created with ADMIN-only access
  □ SETTINGS_ENCRYPTION_KEY generated and added to environment
  □ Zod validation on API inputs
  □ Secret values masked in all API responses and UI
  □ .env fallback works for local development
  □ ORM query logging disabled or filtered to exclude app_settings values
← Back to all resources