API keys and webhook signing secrets in a Node.js and TypeScript backend, with Encore or dotenv and AWS Secrets Manager.
A Node.js backend gathers secrets quickly, such as a payments key and a webhook signing secret. They usually start in a .env file loaded with dotenv and read through process.env. Later, production needs the values stored somewhere safer, and a typo in a variable name turns into undefined at the first request that uses it.
This guide adds two secrets to a TypeScript service that handles payment webhooks: an API key for outgoing calls and a signing secret that verifies incoming requests. It is shown with Encore and with dotenv plus AWS Secrets Manager.
Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that deploys into your own AWS or GCP account. A secret is declared with secret() and read by calling it. The webhook uses a raw endpoint so the signature is computed over the exact bytes that arrived:
import { api, APIError } from "encore.dev/api";
import { secret } from "encore.dev/config";
import log from "encore.dev/log";
import { createHmac, timingSafeEqual } from "node:crypto";
// Values come from `encore secret set` locally and in each environment.
const paymentsApiKey = secret("PaymentsApiKey");
const webhookSecret = secret("PaymentsWebhookSecret");
// Raw endpoint, so the signature is checked against the exact bytes received.
export const paymentsWebhook = api.raw(
{ expose: true, method: "POST", path: "/webhooks/payments" },
async (req, resp) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks);
const expected = createHmac("sha256", webhookSecret()).update(body).digest();
const given = Buffer.from(String(req.headers["x-signature"] ?? ""), "hex");
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
resp.writeHead(401).end("invalid signature");
return;
}
log.info("payment webhook verified", { bytes: body.length });
resp.writeHead(204).end();
},
);
interface RefundParams {
paymentId: string;
}
interface RefundResponse {
status: number;
}
// Outgoing call that authenticates with the API key secret.
export const refund = api(
{ expose: true, method: "POST", path: "/refunds" },
async ({ paymentId }: RefundParams): Promise<RefundResponse> => {
if (!paymentId) throw APIError.invalidArgument("paymentId is required");
const res = await fetch("https://httpbin.org/bearer", {
headers: { Authorization: `Bearer ${paymentsApiKey()}` },
});
return { status: res.status };
},
);
The refund endpoint calls a test URL that only returns 200 when a bearer token is present, which stands in for the payments API. Local values go in .secrets.local.cue at the app root, kept out of git:
PaymentsApiKey: "sk_test_local" PaymentsWebhookSecret: "whsec_local"
Run the service and try a signed, an unsigned and an outgoing request:
When you deploy to your AWS or GCP account, the values are stored in AWS Secrets Manager or GCP Secret Manager. Production, development and preview environments each get their own values, so a test key never reaches production, and since secret() calls are part of the code Encore analyses, a deploy reports secrets that have no value for the environment instead of starting with an empty one.
Secret values are not stored in the repository or in a .env file. Shared values live in Encore's secrets manager and, once deployed, in your cloud's secret manager, so an AI coding agent working in the codebase sees which secrets a service declares but never sees the values in the files it works with, and encore secret list only shows which environments have a value. The exception is a .secrets.local.cue override, a plaintext file in the app root, so keep it out of anything an agent should not read.
An AI agent adding a new integration declares the secret in the file it is already editing and can confirm the flow with encore run.
Run in your terminal to get started locally.
Without Encore, a common pattern is to load .env with dotenv locally and validate process.env once at startup with zod, so a missing value stops the process instead of failing later:
import "dotenv/config";
import { z } from "zod";
const Env = z.object({
PAYMENTS_API_KEY: z.string().min(1),
PAYMENTS_WEBHOOK_SECRET: z.string().min(1),
});
export const env = Env.parse(process.env);
On ECS, each secret lives in Secrets Manager and is mapped into the container in the task definition's secrets array, by ARN. The task execution role needs secretsmanager:GetSecretValue on each one. Values are read when a task starts, so rotating a key means starting new tasks, and staging needs its own secrets and its own task definition that points at them. On Lambda, the usual route is the AWS SDK or the Parameters and Secrets extension, with caching to avoid a call on every invocation.
A .env file sits in the project directory, where any AI coding agent or tool with access to the repository can read it, and that is a common way keys end up in prompts, logs or commits.
In Express, express.json() consumes the body before your handler runs, so webhook routes need express.raw({ type: "application/json" }) on that route to keep the bytes for signature checks. Never log process.env or a config object that holds secrets.
Pick Encore when you want secrets declared in the TypeScript code, checked against each environment on deploy, and stored in Secrets Manager or Secret Manager in your own account. The same app can add a Postgres database whose credentials Encore handles for you.
Pick dotenv with Secrets Manager when your task definitions are already managed in Terraform and the team prefers to keep secrets wiring there.
With Encore, import secret from encore.dev/config, declare const paymentsApiKey = secret("PaymentsApiKey") and read it with paymentsApiKey(). Set the value with encore secret set --type production PaymentsApiKey; Encore stores it in AWS Secrets Manager or GCP Secret Manager in your own account and loads it at startup.
A .env file is fine for local development when it is listed in .gitignore. In production, keep secrets in a secrets manager and inject them at runtime, so they never sit in the image, the repository or a CI log.
Read the raw request body before any JSON parsing, compute an HMAC with node:crypto createHmac, and compare it with timingSafeEqual after checking both buffers have the same length.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.