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
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
# 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
| Threat | Plain Text | App-Layer Encryption | External Vault |
|---|---|---|---|
| Database SQL injection | All secrets exposed | Encrypted blobs only | No secrets in DB |
| Backup file stolen | All secrets readable | Encrypted, useless without key | No secrets in backup |
| Rogue database admin | Can read everything | Sees only ciphertext | No access to vault |
| Application server compromised | Access via app code | Access via app code + key in memory | Access via app code + vault token |
| Setup complexity | Minimal | One env var (master key) | Separate infrastructure |
| Key rotation difficulty | Update DB row | Re-encrypt all rows + update env | Vault 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
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
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;
}| Column | Purpose |
|---|---|
| key | The setting name, e.g. OPENROUTER_API_KEY. Primary key — must be unique. |
| value | The value. For secrets: AES-256-GCM ciphertext (iv:authTag:encrypted). For non-secrets: plain text. |
| is_secret | Boolean flag. Controls UI masking and whether the value is encrypted before storage. |
| description | Human-readable description shown in the admin settings UI. |
| created_at / updated_at | Timestamps 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
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
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
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)
# 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):
Via the Admin Settings UI
Log in as an admin user
Navigate to the settings page (e.g., /god/settings or /dashboard/settings)
Click "Add Setting"
Enter key name: STRIPE_SECRET_KEY
Enter the value: sk_live_...
Check "Mark as secret" (enables encryption + UI masking)
Click Save — the value is encrypted and stored
Via the API
Authenticate as admin to get a session cookie
POST to /api/settings with JSON body: { "key": "STRIPE_SECRET_KEY", "value": "sk_live_...", "isSecret": true }
The value is encrypted server-side before storage
Via SQL (emergency only)
Connect to the database directly
You must encrypt the value yourself or insert plain text and re-encrypt via the API
INSERT INTO app_settings (key, value, is_secret) VALUES ('STRIPE_SECRET_KEY', 'sk_live_...', true)
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
| Layer | Protection |
|---|---|
| Database | Minimal permissions, private networking, SSL/TLS connections, optional RLS |
| At Rest | AES-256-GCM encryption (Approach B) — or plain text (Approach A) with access controls |
| In Transit | TLS for database connections, HTTPS for API endpoints |
| API | Requires authenticated ADMIN session, rate-limited, input validated with Zod |
| UI | Secret values masked (first 8 + last 4 chars visible), full values never sent after initial save |
| Cache | 60-second in-memory TTL, invalidated on write, per-instance only |
| Local Dev | Falls back to .env file (never committed to git via .gitignore) |
| Audit | API access logging, alert on unusual access patterns |
How to Build This — AI Guide
AI Build Guide — Step by Step
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