Need to load secrets in your Node.js app without exposing them? Here’s how.
If you’re still storing API keys or database credentials in .env
files or hardcoding them into your codebase, it’s time for a better approach. Secrets should stay secret—especially in production.
The Problem
Managing sensitive values across environments can get messy fast. Hardcoded secrets are risky, and .env
files aren’t ideal when you’re working with teams or deploying to the cloud. It’s easy to lose control over where that information ends up.
The Solution
AWS Secrets Manager gives you a safe, centralized place to store secrets. You can fetch them at runtime using the AWS SDK, so you don’t need to keep secrets on disk or in code. This snippet shows how to pull them securely in a Node.js app using SDK v3.
TL;DR
- Use AWS Secrets Manager to securely access secrets in Node.js.
- Avoid hardcoding secrets or using local
.env
files in production. - This snippet helps fetch secrets using the AWS SDK v3.
Code Snippet (TypeScript):
import { SecretsManagerClient, GetSecretValueCommand, ResourceNotFoundException, SecretsManagerServiceException,} from '@aws-sdk/client-secrets-manager';import {config} from 'dotenv';
config();
interface CachedSecret { value: string; expiry: number;}
const secretCache = new Map<string, CachedSecret>();
const DEFAULT_CACHE_TTL = 5 * 60 * 1000;
const defaultRegion = process.env.AWS_REGION;
/** * Custom error for when a secret is not found in AWS Secrets Manager. * This is thrown when the secret does not exist or cannot be accessed. */class SecretNotFoundError extends Error { constructor(secretName: string) { super(`Secret "${secretName}" not found in AWS Secrets Manager.`); this.name = 'SecretNotFoundError'; }}
/** * Custom error for when a secret's value is invalid. * This can happen if the secret is binary, empty, or not a string. */class InvalidSecretValueError extends Error { constructor(secretName: string) { super( `Secret "${secretName}" is binary or empty, or does not contain a string value.`, ); this.name = 'InvalidSecretValueError'; }}
let secretsManagerClient: SecretsManagerClient | null = null;
/** * Initializes and returns a singleton SecretsManagerClient. * This prevents recreating the client on every `fetchSecret` call. * @param region - The AWS region to use for the client. * @returns An initialized SecretsManagerClient instance. */function getSecretsManagerClient(region: string): SecretsManagerClient { if (!region) { throw new Error( 'AWS_REGION is not defined. Please set it in your .env file or pass it as an argument.', ); } if (!secretsManagerClient) { secretsManagerClient = new SecretsManagerClient({region}); } return secretsManagerClient;}
/** * Fetches a secret's string value from AWS Secrets Manager with optional caching. * @param secretName - The name or ARN of the secret. * @param options - Optional configuration for fetching the secret. * @param options.region - Overrides the default AWS region for this fetch operation. * @param options.cacheTTL - Time-to-live for the cached secret in milliseconds. Set to 0 to disable caching for this call. * @returns A promise that resolves to the secret string. * @throws {SecretNotFoundError} If the secret does not exist. * @throws {InvalidSecretValueError} If the secret's value is binary or empty. * @throws {Error} For other AWS SDK or network-related errors. */export async function fetchSecret( secretName: string, options?: {region?: string; cacheTTL?: number},): Promise<string> { const region = options?.region || defaultRegion; const cacheTTL = options?.cacheTTL !== undefined ? options.cacheTTL : DEFAULT_CACHE_TTL;
if (!region) { throw new Error( "AWS_REGION is not defined. Ensure it's in your .env file or passed in options.", ); }
if (cacheTTL > 0) { const cached = secretCache.get(secretName); if (cached && Date.now() < cached.expiry) { return cached.value; } }
const client = getSecretsManagerClient(region);
try { const command = new GetSecretValueCommand({SecretId: secretName}); const response = await client.send(command);
if (response.SecretString) { const secretValue = response.SecretString; if (cacheTTL > 0) { secretCache.set(secretName, { value: secretValue, expiry: Date.now() + cacheTTL, }); } return secretValue; } else if (response.SecretBinary) { throw new InvalidSecretValueError(secretName); } else { throw new InvalidSecretValueError(secretName); } } catch (error: any) { console.error(`[ERROR] Failed to fetch secret "${secretName}":`, error); if (error instanceof ResourceNotFoundException) { throw new SecretNotFoundError(secretName); } else if (error instanceof SecretsManagerServiceException) { throw new Error( `AWS Secrets Manager error for "${secretName}": ${error.message} (Code: ${error.name})`, ); } else { throw new Error( `An unexpected error occurred while fetching secret "${secretName}": ${error.message}`, ); } } finally { // Add any cleanup or finalization logic here. // For example, if you had an active connection or resource to close. // In this specific code, there isn't an obvious resource to clean up // within the fetchSecret function itself, as the client is a singleton // and caching is handled by a Map. // However, for demonstration, you could log something: console.log(`[INFO] Finished attempting to fetch secret "${secretName}".`); }}
Why This Matters
This setup helps keep your apps more secure and your secrets off disk. It fits perfectly into cloud-native workflows, especially when you’re using IAM roles or CI/CD pipelines. It’s simple, flexible, and secure.
Over to you
How do you handle secrets in your projects? Tried this approach before?