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-managerCode 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 stringRuntime 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#
- Validate all environment variables at application startup
- Use different secret stores for development vs production
- Never store plain API keys - always hash them
- Return full API keys only once during creation
- 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
Related Patterns#
- Input Validation - Validate all input
- Encryption - Data encryption patterns
- Audit Logging - Track secret access
- Environment Management - Environment configuration