Secrets Management

Patterns for secure handling of secrets, credentials, and API keys.

Overview#

Proper secrets management protects your application from credential leaks. This pattern covers:

  • Environment variable validation
  • Runtime secret loading
  • Encrypted secrets storage
  • API key generation and validation
  • Secret rotation

Prerequisites#

npm install zod # Optional for AWS Secrets Manager npm install @aws-sdk/client-secrets-manager

Code Example#

Environment Variable Validation#

1// lib/env.ts 2import { z } from 'zod' 3 4const envSchema = z.object({ 5 // Database 6 DATABASE_URL: z.string().url(), 7 8 // Auth 9 NEXTAUTH_SECRET: z.string().min(32), 10 NEXTAUTH_URL: z.string().url(), 11 12 // Stripe 13 STRIPE_SECRET_KEY: z.string().startsWith('sk_'), 14 STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'), 15 NEXT_PUBLIC_STRIPE_KEY: z.string().startsWith('pk_'), 16 17 // Email 18 RESEND_API_KEY: z.string().startsWith('re_'), 19 20 // Optional 21 SENTRY_DSN: z.string().url().optional() 22}) 23 24// Validate at startup 25function validateEnv() { 26 const parsed = envSchema.safeParse(process.env) 27 28 if (!parsed.success) { 29 console.error('Invalid environment variables:') 30 console.error(parsed.error.flatten().fieldErrors) 31 throw new Error('Invalid environment configuration') 32 } 33 34 return parsed.data 35} 36 37export const env = validateEnv() 38 39// Type-safe access: env.DATABASE_URL is guaranteed to be a string

Runtime Secret Loading#

1// lib/secrets.ts 2interface SecretStore { 3 get(key: string): Promise<string | undefined> 4} 5 6// Local development 7class EnvSecretStore implements SecretStore { 8 async get(key: string) { 9 return process.env[key] 10 } 11} 12 13// AWS Secrets Manager 14class AwsSecretStore implements SecretStore { 15 private client: SecretsManagerClient 16 17 constructor() { 18 this.client = new SecretsManagerClient({}) 19 } 20 21 async get(key: string) { 22 const command = new GetSecretValueCommand({ SecretId: key }) 23 const response = await this.client.send(command) 24 return response.SecretString 25 } 26} 27 28// Factory 29export function createSecretStore(): SecretStore { 30 if (process.env.NODE_ENV === 'development') { 31 return new EnvSecretStore() 32 } 33 return new AwsSecretStore() 34} 35 36// Usage 37const secrets = createSecretStore() 38const apiKey = await secrets.get('STRIPE_SECRET_KEY')

Encrypted Secrets#

1// lib/crypto.ts 2import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto' 3import { promisify } from 'util' 4 5const scryptAsync = promisify(scrypt) 6 7export async function encryptSecret(plaintext: string, password: string): Promise<string> { 8 const salt = randomBytes(16) 9 const key = (await scryptAsync(password, salt, 32)) as Buffer 10 const iv = randomBytes(16) 11 12 const cipher = createCipheriv('aes-256-gcm', key, iv) 13 const encrypted = Buffer.concat([ 14 cipher.update(plaintext, 'utf8'), 15 cipher.final() 16 ]) 17 const authTag = cipher.getAuthTag() 18 19 // Combine: salt + iv + authTag + encrypted 20 const combined = Buffer.concat([salt, iv, authTag, encrypted]) 21 return combined.toString('base64') 22} 23 24export async function decryptSecret(ciphertext: string, password: string): Promise<string> { 25 const combined = Buffer.from(ciphertext, 'base64') 26 27 const salt = combined.subarray(0, 16) 28 const iv = combined.subarray(16, 32) 29 const authTag = combined.subarray(32, 48) 30 const encrypted = combined.subarray(48) 31 32 const key = (await scryptAsync(password, salt, 32)) as Buffer 33 34 const decipher = createDecipheriv('aes-256-gcm', key, iv) 35 decipher.setAuthTag(authTag) 36 37 const decrypted = Buffer.concat([ 38 decipher.update(encrypted), 39 decipher.final() 40 ]) 41 42 return decrypted.toString('utf8') 43}

API Key Storage#

1// lib/api-keys.ts 2import { randomBytes, createHash } from 'crypto' 3import { prisma } from '@/lib/db' 4 5export async function createApiKey(userId: string, name: string) { 6 // Generate key: prefix_randomBytes 7 const keyValue = `bs_${randomBytes(24).toString('hex')}` 8 9 // Hash for storage (never store plain key) 10 const keyHash = createHash('sha256').update(keyValue).digest('hex') 11 12 // Store last 4 chars for display 13 const keyHint = keyValue.slice(-4) 14 15 await prisma.apiKey.create({ 16 data: { 17 userId, 18 name, 19 keyHash, 20 keyHint 21 } 22 }) 23 24 // Only return the full key once! 25 return keyValue 26} 27 28export async function validateApiKey(key: string) { 29 const keyHash = createHash('sha256').update(key).digest('hex') 30 31 const apiKey = await prisma.apiKey.findUnique({ 32 where: { keyHash }, 33 include: { user: true } 34 }) 35 36 if (!apiKey || apiKey.revokedAt) { 37 return null 38 } 39 40 // Update last used 41 await prisma.apiKey.update({ 42 where: { id: apiKey.id }, 43 data: { lastUsedAt: new Date() } 44 }) 45 46 return apiKey.user 47}

Secure Token Generation#

1// lib/tokens.ts 2import { randomBytes } from 'crypto' 3 4// URL-safe token 5export function generateToken(bytes = 32): string { 6 return randomBytes(bytes).toString('base64url') 7} 8 9// Numeric OTP 10export function generateOtp(length = 6): string { 11 const max = Math.pow(10, length) - 1 12 const randomNum = parseInt(randomBytes(4).toString('hex'), 16) 13 return String(randomNum % (max + 1)).padStart(length, '0') 14} 15 16// Short code (for user input) 17export function generateCode(length = 8): string { 18 const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // No confusing chars 19 let code = '' 20 21 for (let i = 0; i < length; i++) { 22 const randomIndex = randomBytes(1)[0] % chars.length 23 code += chars[randomIndex] 24 } 25 26 return code 27}

Secret Rotation#

1// lib/rotation.ts 2import { prisma } from '@/lib/db' 3import { encryptSecret, decryptSecret } from './crypto' 4 5export async function rotateEncryptionKey() { 6 const oldKey = process.env.ENCRYPTION_KEY! 7 const newKey = process.env.NEW_ENCRYPTION_KEY! 8 9 // Find all encrypted records 10 const records = await prisma.secretData.findMany() 11 12 for (const record of records) { 13 // Decrypt with old key 14 const plaintext = await decryptSecret(record.encryptedValue, oldKey) 15 16 // Re-encrypt with new key 17 const newEncrypted = await encryptSecret(plaintext, newKey) 18 19 // Update record 20 await prisma.secretData.update({ 21 where: { id: record.id }, 22 data: { encryptedValue: newEncrypted } 23 }) 24 } 25 26 console.log(`Rotated ${records.length} secrets`) 27}

Usage Instructions#

  1. Validate all environment variables at application startup
  2. Use different secret stores for development vs production
  3. Never store plain API keys - always hash them
  4. Return full API keys only once during creation
  5. Implement secret rotation for encryption keys

Best Practices#

  • Never commit secrets - Use .env.local and .gitignore
  • Validate early - Check environment variables at startup
  • Hash API keys - Store hashes, not plain text
  • Rotate regularly - Implement key rotation procedures
  • Audit access - Track when secrets are accessed
  • Use prefixes - Make key types identifiable (sk_, pk_, re_)
  • Limit scope - Use least-privilege access for secrets