Storing webhook secrets in a secrets manager

WEBHOOK_SECRET in the environment works right up until you have four hundred tenants, an auditor asking who read which secret and when, and a rotation that requires a redeploy of every consumer. Environment variables have no version history, no per-read audit trail, no access control finer than “whoever can read the process”, and they leak into crash dumps, container inspect output, and CI logs. This page covers moving webhook signing secrets into AWS Secrets Manager or HashiCorp Vault without paying an API call per delivery — the constraint that makes this harder than it first looks. It extends the Key Rotation Strategies reference, and it is the storage layer underneath the procedure in Zero-downtime webhook secret rotation: once secrets live in a managed store, the dual-secret overlap becomes a property of the stored document rather than a comma-separated environment variable.

The tension to resolve is latency against freshness. A high-volume webhook endpoint verifies thousands of signatures per second, and Secrets Manager is a rate-limited, network-round-trip API billed per call — you cannot fetch on every request. But a cached secret is a stale secret, and staleness during a rotation is exactly when it hurts. Everything below is a way of making the cache both fast and provably bounded in how wrong it can be.

Secret retrieval path An inbound webhook reaches the verifier, which reads from an in-process TTL cache and only calls the managed secrets store on a miss, under a narrowly scoped IAM policy. Inbound webhook Signature verifier In-process cache TTL 300 s, capped size Secrets Manager or Vault KV v2 cache miss versions IAM: read one prefix audited on every call No secret available fail closed with 503
The store sits behind the cache, not in the request path: a warm entry means the verifier never leaves the process, and a cold one that cannot be filled rejects rather than guesses.

Prerequisites

Step 1: Model the secret and choose a per-tenant naming scheme

Store a small JSON document rather than a bare string. A document lets one secret carry the overlap window — the current material and the outgoing one — so the verifier gets everything it needs from a single read, and rotation becomes an update to one object rather than a coordinated edit across several.

export interface SecretMaterial {
  readonly id: string;          // stable label, e.g. "2026-07-25"
  readonly value: string;       // the HMAC key, hex-encoded
  readonly activeFrom: string;  // ISO-8601
}

export interface WebhookSecretDocument {
  readonly version: 1;
  /** Newest first. Length 1 normally, 2 during an overlap window. */
  readonly materials: readonly SecretMaterial[];
}

const TENANT_PATTERN = /^[a-z0-9][a-z0-9-]{2,62}$/;

/** Deterministic, hierarchical, and safe to build from untrusted input. */
export function secretName(env: string, provider: string, tenantId: string): string {
  if (!TENANT_PATTERN.test(tenantId) || !TENANT_PATTERN.test(provider)) {
    throw new Error('ERR_SECRET_NAME_INVALID');
  }
  return `webhook/${env}/${provider}/${tenantId}`;
}

The TENANT_PATTERN guard is not cosmetic. Tenant identifiers usually arrive from a request path or a header, and secret names are path-like in both Secrets Manager and Vault. An unvalidated identifier containing ../ or a wildcard turns a lookup into a probe of your entire secret hierarchy, limited only by whatever your IAM policy forgot to exclude.

Naming scheme Reads per verification Rotation blast radius Fits when
One secret for all tenants 1 Every tenant at once Single-tenant products only
webhook/{env}/{provider} 1 All tenants of one provider You share one secret per integration
webhook/{env}/{provider}/{tenant} 1 One tenant The default for multi-tenant systems
One secret per endpoint URL 1 One endpoint Tenants register many endpoints each
Secret per tenant per key generation 2 or more One generation Avoid — the document already versions material

Engineering Note: Put the environment first in the name. webhook/prod/... and webhook/staging/... are then separable by a single ARN prefix in IAM, which is what makes the least-privilege policy in Step 4 expressible in one line instead of an enumeration.

Step 2: Cache the secret in-process behind a bounded TTL

Secrets Manager GetSecretValue is quota-limited and adds tens of milliseconds to a request that should complete in single-digit milliseconds. Cache it. The cache needs three properties that a plain Map does not give you: a TTL, a size cap so a tenant enumeration attack cannot exhaust memory, and single-flight behaviour so a cold key under load produces one API call rather than five hundred.

Staleness window during rotation A rotation lands in the store while the verifier cache still holds the previous secret, and the divergence lasts until the cache TTL expires. secret rotated in store cache TTL expires Verifier cache contents old material still served new material fetched Sender signing key old new divergence lasts up to one full TTL time
The TTL is the worst-case rotation lag: keeping the outgoing material in the same document is what turns this window from an outage into a no-op.
import {
  SecretsManagerClient,
  GetSecretValueCommand,
  ResourceNotFoundException,
} from '@aws-sdk/client-secrets-manager';

interface Entry {
  readonly document: WebhookSecretDocument;
  readonly expiresAt: number;
}

export class SecretCache {
  private readonly entries = new Map<string, Entry>();
  private readonly inflight = new Map<string, Promise<WebhookSecretDocument>>();

  constructor(
    private readonly client: SecretsManagerClient,
    private readonly ttlMs = 300_000,
    private readonly maxEntries = 5_000,
  ) {}

  async get(name: string): Promise<WebhookSecretDocument> {
    const hit = this.entries.get(name);
    if (hit && hit.expiresAt > Date.now()) {
      // Refresh recency for the LRU bound.
      this.entries.delete(name);
      this.entries.set(name, hit);
      return hit.document;
    }

    // Single flight: concurrent misses on the same name share one API call.
    const existing = this.inflight.get(name);
    if (existing) return existing;

    const pending = this.fetch(name).finally(() => this.inflight.delete(name));
    this.inflight.set(name, pending);
    return pending;
  }

  invalidate(name: string): void {
    this.entries.delete(name);
  }

  private async fetch(name: string): Promise<WebhookSecretDocument> {
    let raw: string;
    try {
      const res = await this.client.send(
        new GetSecretValueCommand({ SecretId: name, VersionStage: 'AWSCURRENT' }),
      );
      raw = res.SecretString ?? '';
    } catch (err) {
      if (err instanceof ResourceNotFoundException) throw new Error('ERR_SECRET_NOT_FOUND');
      throw new Error('ERR_SECRET_STORE_UNAVAILABLE');
    }

    const document = JSON.parse(raw) as WebhookSecretDocument;
    if (document.version !== 1 || document.materials.length === 0) {
      throw new Error('ERR_SECRET_DOCUMENT_INVALID');
    }

    // Evict the least-recently-used entry once the cap is reached.
    if (this.entries.size >= this.maxEntries) {
      const oldest = this.entries.keys().next().value;
      if (oldest !== undefined) this.entries.delete(oldest);
    }
    this.entries.set(name, { document, expiresAt: Date.now() + this.ttlMs });
    return document;
  }
}

Note that failures are never cached. A transient ERR_SECRET_STORE_UNAVAILABLE that landed in the map would keep rejecting deliveries for a full TTL after the store recovered. Only successful reads populate an entry.

Wiring it into verification is then a two-line change from an environment variable read, and the overlap window comes along for free:

import crypto from 'node:crypto';

const cache = new SecretCache(new SecretsManagerClient({}));

export async function verifyDelivery(
  tenantId: string,
  rawBody: Buffer,
  signatureHex: string,
): Promise<{ ok: boolean; materialId?: string }> {
  const doc = await cache.get(secretName(process.env.APP_ENV!, 'acme', tenantId));
  const provided = Buffer.from(signatureHex, 'hex');

  let matched: string | undefined;
  for (const material of doc.materials) {
    const expected = crypto
      .createHmac('sha256', Buffer.from(material.value, 'hex'))
      .update(rawBody)
      .digest();
    // Evaluate every material so timing does not reveal which one matched.
    if (expected.length === provided.length && crypto.timingSafeEqual(expected, provided)) {
      matched = material.id;
    }
  }
  return { ok: matched !== undefined, materialId: matched };
}

Emit materialId as a metric label. When it reads 2026-07-25 for a full TTL and never the previous label, the outgoing material is provably unused and safe to drop from the document.

Step 3: Invalidate the cache the moment a rotation lands

A TTL alone means a rotation takes up to five minutes to reach every process, and a much longer TTL — which you want, for cost and latency — makes that worse. Push invalidation on top of the TTL: Secrets Manager emits a rotation event to EventBridge, and Vault can fire a webhook from a rotation job. Treat the push as an optimisation and the TTL as the guarantee, because a missed event must not be able to strand a process on old material forever.

Cache entry state machine States for one cached secret: absent, fetching, fresh, stale, serving under grace when the store is unreachable, and failing closed once grace is exhausted. Lifecycle of one cached tenant secret Absent no cached value Fetching single flight Fresh within TTL Stale TTL elapsed Grace serve store unreachable Fail closed reject with 503 first request fetch ok TTL or event refresh ok fetch failed grace over store recovers Grace serving is a deliberate trade: bounded staleness in exchange for surviving a store outage.
Only the grace state is optional — decide its duration explicitly, because leaving it unbounded is indistinguishable from never expiring the cache at all.
import type { Handler } from 'aws-lambda';

/**
 * EventBridge rule:
 *   source = ["aws.secretsmanager"]
 *   detail-type = ["AWS Secrets Manager Rotation Succeeded"]
 * Fan this out to every verifier instance (SNS topic, Redis pub/sub, or a
 * control-plane endpoint) and call cache.invalidate on each.
 */
interface RotationEvent {
  readonly detail: { readonly additionalEventData?: { readonly SecretId?: string } };
}

export const onRotation: Handler<RotationEvent> = async (event) => {
  const arn = event.detail.additionalEventData?.SecretId;
  if (!arn) return;
  // ARN tail is "webhook/prod/acme/tenant-42-AbCdEf"; strip the 6-char suffix.
  const name = arn.split(':secret:')[1]?.replace(/-[A-Za-z0-9]{6}$/, '');
  if (!name?.startsWith('webhook/')) return;
  await broadcastInvalidation(name);
};

// Receiving side, inside each verifier process.
export function subscribeToInvalidations(cache: SecretCache, bus: InvalidationBus): void {
  bus.on('secret.rotated', (name: string) => {
    cache.invalidate(name);
    // Do NOT prefetch here. A rotation across thousands of tenants would
    // stampede the store; let the next real delivery repopulate lazily.
  });
}

Refusing to prefetch on invalidation is the important line. A provider-wide rotation invalidates every tenant entry at once, and an eager refill turns that into thousands of simultaneous GetSecretValue calls — throttling, then failures, then a self-inflicted outage during a routine security operation. Lazy repopulation spreads the same work across the arrival rate of real traffic. If you do want warm caches, add jitter to the TTL itself so entries expire on a spread rather than in lockstep, the same reasoning behind adding jitter to webhook retry backoff.

Step 4: Scope the IAM policy to a single read action

The verifier reads. It never writes, rotates, deletes, tags, or lists. Every one of those verbs on the verifier’s role is a way for a compromised request handler to escalate from reading one tenant’s secret to overwriting all of them — and an overwritten signing secret is a silent, total authentication bypass, because the attacker then controls what a valid signature looks like.

Verifier read policy anatomy A task-role policy granting only GetSecretValue on a narrow ARN prefix under a principal-tag condition, with each restriction annotated. Task-role policy attached to the verifier only "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": "arn:aws:secretsmanager:eu-west-1: 123456789012:secret:webhook/prod/*", "Condition": {"StringEquals": { "aws:PrincipalTag/svc": "webhook-verifier"}} read only no Put, Update, or Delete one environment prefix prod cannot reach staging principal tag required blocks role reuse elsewhere Rotation and deletion live on a separate administrative principal so a compromised verifier can never overwrite the material it verifies with
Three independent narrowings — action, resource prefix, and principal tag — mean any one of them being wrong still leaves the blast radius bounded.
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadWebhookSigningSecrets",
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:webhook/prod/*",
      "Condition": {
        "StringEquals": { "aws:PrincipalTag/svc": "webhook-verifier" }
      }
    },
    {
      "Sid": "DecryptWithTheWebhookKeyOnly",
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:eu-west-1:123456789012:key/8f2c1b60-0000-4a11-9f00-webhookcmk1",
      "Condition": {
        "StringEquals": { "kms:ViaService": "secretsmanager.eu-west-1.amazonaws.com" }
      }
    }
  ]
}

The second statement is the one teams forget. A customer-managed KMS key means GetSecretValue also needs kms:Decrypt, and the kms:ViaService condition ensures that grant only works through Secrets Manager — the role cannot use the key to decrypt anything else. The Vault equivalent is a policy with capabilities = ["read"] on secret/data/webhook/prod/* and no create, update, delete, or list; omitting list matters, because a role that can enumerate the path learns your full tenant roster even if it cannot read a single value.

Verification and testing

import { SecretCache } from './secret-cache';

class CountingClient {
  public calls = 0;
  async send(): Promise<{ SecretString: string }> {
    this.calls += 1;
    await new Promise((r) => setTimeout(r, 50));
    return {
      SecretString: JSON.stringify({
        version: 1,
        materials: [{ id: '2026-07-25', value: 'aabb', activeFrom: '2026-07-25T00:00:00Z' }],
      }),
    };
  }
}

test('concurrent misses collapse into a single store call', async () => {
  const client = new CountingClient();
  const cache = new SecretCache(client as never, 300_000, 100);
  const results = await Promise.all(
    Array.from({ length: 50 }, () => cache.get('webhook/prod/acme/tenant-42')),
  );
  expect(client.calls).toBe(1);
  expect(new Set(results.map((r) => r.materials[0].id)).size).toBe(1);
});

test('a warm entry never touches the store again', async () => {
  const client = new CountingClient();
  const cache = new SecretCache(client as never, 300_000, 100);
  for (let i = 0; i < 1_000; i += 1) await cache.get('webhook/prod/acme/tenant-42');
  expect(client.calls).toBe(1);
});
# Prove the role cannot write, only read, before you ship it.
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/webhook-verifier \
  --action-names secretsmanager:GetSecretValue secretsmanager:PutSecretValue \
  --resource-arns arn:aws:secretsmanager:eu-west-1:123456789012:secret:webhook/prod/acme/tenant-42-AbCdEf \
  --query 'EvaluationResults[].{action:EvalActionName,decision:EvalDecision}' --output table
# Expect: GetSecretValue allowed, PutSecretValue implicitDeny

Failure modes and gotchas

Frequently Asked Questions

What is a sensible TTL for the cache?

Five minutes is a good starting point, and the exact number matters less once push invalidation is in place, because the TTL then only bounds how wrong you can be when an event is missed. Choose the longest value your compliance story tolerates as a worst case, and add jitter so entries across the fleet do not all expire in the same second. A very short TTL chosen out of nervousness buys a higher call rate and the same failure mode.

Does a secret per tenant get expensive at scale?

It becomes a real line item. Managed stores bill a monthly fee per stored secret on top of a charge per API call, so four hundred tenants is four hundred recurring charges while caching keeps the call-based portion near zero. When the per-secret fee dominates, group tenants that genuinely share an integration under one document; the lever is the naming scheme, never a shorter cache.

Should secrets be loaded at startup instead of lazily?

Eager loading does not scale past a handful of secrets and couples every process start to the store's availability, turning a brief outage into a fleet that cannot boot. Load lazily and cache, but do probe one known secret from the health check so a broken role or a missing permission fails the deployment rather than the first delivery.

What happens on a full fleet restart when every cache is cold?

Every instance misses on its first delivery for each tenant, and the single-flight guard collapses duplicates only within a process, never across the fleet. A hundred pods multiplied by a few hundred active tenants is enough to hit the store's rate limit during an ordinary deploy. Roll deployments in waves, keep quota headroom for the cold-start burst, and read throttling during a deploy as a capacity signal rather than a transient.

Does a multi-region deployment change the setup?

It adds a replica per region, and each replica is a distinct resource with its own name under the policy prefix and its own regional encryption key that the local role must be allowed to use. Replication is asynchronous, so a rotation lands in the primary before the replicas and a verifier in a lagging region can briefly hold material the primary has already replaced. Your overlap window has to cover that lag as well as the cache TTL.

How should developers get a secret for local work?

From a separate hierarchy holding values that were never used in production, reached with short-lived credentials from your identity provider rather than a static key in a dotfile. Copying a production value onto a laptop defeats the audit trail, and that copy is the one that will still exist after the value has been rotated. To reproduce a production failure locally, replay a captured delivery re-signed with the development material.

Is a cheaper encrypted parameter store good enough instead?

It holds the value safely enough, so the question is what you give up: native rotation hooks, versioned staging labels, and the per-read audit granularity that makes an access review answerable. If your rotation is driven by your own pipeline and your audit requirements are light, it is a defensible choice. A plain configuration object mounted into the workload is not, since that is an environment variable with extra steps.