Written by Huifer, solo developer and maintainer of TanStack Ship. I have managed secrets on Cloudflare Workers across 12+ production SaaS applications since 2023 — Stripe keys, Postgres credentials via Hyperdrive, OpenAI tokens, a per-tenant Secrets Store binding for enterprise customers, and one 2024 incident where I shipped a webhook signing key into a public GitHub repo. This guide is the consolidated version of every pattern I actually use, the failure modes I have debugged, and the trade-offs I would weigh again today. TanStack Ship is my paid product; that bias is declared.
Verified sources: Cloudflare Workers Secrets · Secrets Store · Wrangler secret commands · Environment variables · Hyperdrive · Cloudflare R2 · workerd runtime · OWASP Secrets Management Cheat Sheet
Last updated: 2026-08-17 · Changelog
TL;DR: Secrets management on Cloudflare Workers is a four-layer system:
.dev.varsfor local development,wrangler secret putfor per-environment production secrets, plaintext environment variables inwrangler.jsoncfor non-sensitive config, and the Secrets Store binding for per-tenant credential isolation. The right access pattern is a typedEnvinterface that fails to compile when a secret is missing, server-only guards that prevent client-bundle leakage, and a rotation cadence that treats every secret as expiring. The mistakes that page you at 2 a.m. are committed.envfiles, plaintext secrets in wrangler config, and per-tenant secrets that share a single global key. This guide covers all four layers, the rotation playbook I actually run, the Secrets Store pattern for multi-tenant SaaS, and the incident-response checklist I keep on a sticky note.
The Threat Model: Why Edge Secrets Are Different
The origin-vs-edge secret divide
Most secret-management advice you will find online is written for a Lambda-style world where your code runs in a region, your database lives in the same region, and secrets are fetched from a single centralized vault at boot. That model breaks down on the edge.
A Cloudflare Worker runs in a V8 isolate inside one of Cloudflare's ~330 data centers, often only milliseconds from the end user. There is no boot phase in the traditional sense — your code is already warm inside workerd. There is no central secret store to phone home to on cold start, and even if there were, the 80 ms round trip would erase the latency advantage of running on the edge. Cloudflare's solution is to push secrets into the same data center as the Worker at deploy time, encrypted at rest, decrypted inside the isolate. The model is conceptually closer to "secrets baked into the runtime" than "secrets fetched from a vault."
That single architectural choice explains every downstream property of Workers secret management — both good and bad. Good: secrets are available in under a millisecond, no network round trip, no cold-start penalty. Bad: a compromised CI pipeline that can deploy can leak every secret in the account; secret rotation requires a redeploy, not a runtime API call.
The three categories of secrets
Before we get into mechanics, every secret I have ever put into a Worker falls into one of three buckets, and the right storage for each is different:
- Application secrets — Stripe API keys, OpenAI tokens, S3 credentials, signing keys. One value per environment, rotated on a schedule, owned by me.
- Infrastructure secrets — D1 database IDs, R2 bucket tokens, Hyperdrive connection strings. These are more like identifiers than secrets, but they still should not live in source control.
- User secrets — the API key one of my enterprise customers pastes into a dashboard to let my SaaS call their downstream API. These are not mine to rotate, are scoped per-tenant, and may number in the thousands.
The mistake most teams make is treating all three with the same wrangler secret put workflow. Application secrets are fine there. User secrets are not — they need per-tenant isolation, audit trails, and encryption-at-rest semantics that the wrangler command line cannot provide. That is what the Secrets Store binding is for, and I will come back to it.
The Wrangler Secret Lifecycle in Production
.dev.vars for local, wrangler secret for prod
The mental model is straightforward. Local development reads from a .dev.vars file in the project root. Production reads from secrets pushed through the Wrangler CLI or the Cloudflare dashboard. The two never touch the same code path.
# .dev.vars (gitignored, NEVER committed)
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
WEBHOOK_SIGNING_KEY=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
# Production: per-environment secret push
wrangler secret put STRIPE_SECRET_KEY --env production
wrangler secret put STRIPE_SECRET_KEY --env staging
wrangler secret put OPENAI_API_KEY --env production
Wrangler prompts for the value interactively (or reads from stdin in CI), encrypts the value with a per-account key, and pushes it to the Workers runtime. The value never appears in your shell history if you pipe it from a file:
# CI: rotate a Stripe key without leaking it to logs
echo "$STRIPE_SECRET_KEY_NEW" | wrangler secret put STRIPE_SECRET_KEY --env production
The --env flag is the under-appreciated part. Most teams I have audited use a single production environment and accidentally promote staging secrets to prod during rotation. If you operate more than one environment, pass --env every time. Treat it as mandatory.
Bulk import with wrangler secret bulk
When you onboard a new environment or migrate from another platform, the bulk command reads a JSON file and pushes every key it finds:
wrangler secret bulk ./secrets.production.json --env production
The file format is a flat JSON object. Wrangler reads it, uploads each value, and exits non-zero if any push fails. I keep a secrets.production.json template in my private ops repo — gitignored, encrypted at rest with age, never present on a developer laptop. For the full command reference, see the Wrangler secret commands documentation.
Rotation without downtime
Secrets do not get rotated because something is wrong; they get rotated because something will eventually go wrong. The pattern I use for any secret that another service depends on — Stripe, OpenAI, Postgres — is dual-write during rotation:
# Step 1: push the new key as a second secret
wrangler secret put STRIPE_SECRET_KEY_NEW --env production
# Step 2: deploy a Worker version that accepts EITHER key
# (the old STRIPE_SECRET_KEY as fallback, the new _NEW as primary)
# Step 3: switch the upstream provider to issue the new key
# Step 4: verify traffic on the new key for at least 24 hours
# Step 5: deploy a Worker version that only reads STRIPE_SECRET_KEY_NEW
# Step 6: wrangler secret delete STRIPE_SECRET_KEY_OLD --env production
The 22-minute outage I had in early 2025 was because I tried to rotate Stripe's webhook signing key in a single step — pushed the new key, redeployed, and discovered that Stripe does not replay old webhook events with the new signing secret. The fix was to accept both keys in the verification path for 24 hours. The lesson: dual-write is not paranoia, it is the only safe way to rotate a secret whose consumers cannot be controlled by you.
Type-Safe Access Patterns That Prevent Leaks
The typed Env interface is a security control
The most effective secret-leakage prevention I have ever shipped is not a tool — it is a TypeScript interface that fails to compile when a secret is missing:
// src/env.ts
export interface Env {
// Application secrets — wrangler secret put
STRIPE_SECRET_KEY: string;
OPENAI_API_KEY: string;
WEBHOOK_SIGNING_KEY: string;
// Infrastructure bindings — wrangler.jsonc
DB: D1Database;
KV_CACHE: KVNamespace;
R2_BUCKET: R2Bucket;
HYPERDRIVE: Hyperdrive;
// Per-tenant Secrets Store binding
SECRETS_STORE: SecretsStore;
// Plaintext non-sensitive config — wrangler.jsonc vars
ENVIRONMENT: "development" | "staging" | "production";
LOG_LEVEL: "debug" | "info" | "warn" | "error";
}
// src/handlers/checkout.ts
export async function handleCheckout(request: Request, env: Env) {
// TypeScript fails to compile if STRIPE_SECRET_KEY is missing from Env
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
// ...
}
This pattern looks trivial until you compare it against the alternative: process.env.STRIPE_SECRET_KEY lookups scattered across the codebase, any of which could silently return undefined and turn into a runtime crash in production. The typed interface converts a runtime security failure into a compile-time deployment failure. For the broader pattern of typed env access across a TanStack Start codebase, see TanStack Start environment variable configuration.
Server-only guards against client-bundle leakage
Cloudflare Workers do not bundle secrets into client JavaScript — the Worker code never reaches a browser. But the moment you start using TanStack Start or any framework that builds both client and server bundles from the same source tree, a careless import { STRIPE_KEY } from "./env" can land your key in a .js file the browser downloads.
The guard I use in every TanStack Start project is the server-only import boundary:
// src/lib/stripe.ts
import "server-only"; // build fails if imported into a client component
import Stripe from "stripe";
import type { Env } from "../env";
export function getStripe(env: Env): Stripe {
return new Stripe(env.STRIPE_SECRET_KEY);
}
The server-only package throws at build time if any code on the client-side import graph reaches it. Combined with the typed Env interface, this gives you two independent layers of protection: TypeScript catches missing secrets at compile time, and server-only catches misplaced secrets at build time. For the full feature set this enables, see the TanStack Ship features page.
Hiding secrets from logs and traces
Even when secrets never leave the server, they show up in places you do not expect: structured logs, error reports, Sentry breadcrumbs, distributed traces. The fix is a logger that redacts known secret keys:
// src/lib/logger.ts
const SECRET_KEYS = new Set([
"STRIPE_SECRET_KEY",
"OPENAI_API_KEY",
"WEBHOOK_SIGNING_KEY",
]);
function redact(value: unknown, depth = 0): unknown {
if (depth > 5) return "[depth-cap]";
if (value === null || typeof value !== "object") return value;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (SECRET_KEYS.has(k)) {
out[k] = "[redacted]";
} else {
out[k] = redact(v, depth + 1);
}
}
return out;
}
export function log(env: Env, level: string, data: Record<string, unknown>) {
console.log(JSON.stringify({ level, env: env.ENVIRONMENT, ...redact(data) }));
}
This is not a complete solution — an attacker who controls a log field name could bypass the redaction — but it is the 90% solution. Pair it with Cloudflare Workers Logs and you have a defensible audit trail without exposing secrets to your log aggregation vendor. The OWASP Secrets Management Cheat Sheet covers the broader principle.
Advanced Patterns: Per-Environment, Per-Tenant, Per-Request
Environment-scoped config
Not everything in wrangler.jsonc is a secret. The non-sensitive configuration — feature flags, environment name, public API endpoints — belongs in plaintext vars:
// wrangler.jsonc
{
"name": "tanstack-ship-prod",
"vars": {
"ENVIRONMENT": "production",
"LOG_LEVEL": "info",
"PUBLIC_API_URL": "https://api.tanstackship.com"
},
"env": {
"staging": {
"vars": {
"ENVIRONMENT": "staging",
"LOG_LEVEL": "debug"
}
}
}
}
The rule I follow: anything that would not cause harm if it appeared in a public GitHub repo goes into vars. Anything that would goes into wrangler secret put. This split keeps the diff in code review readable and prevents the common mistake of promoting a staging-only secret to production by accident. For how this fits into a deploy pipeline, see the Cloudflare Workers production implementation guide.
Per-tenant secrets with the Secrets Store
The pattern I have shipped most often for enterprise SaaS is per-tenant Secrets Store isolation. Each tenant pastes their own API key into my dashboard, I encrypt and store it in the Secrets Store, and the Worker reads it at request time:
// wrangler.jsonc — bind the Secrets Store
{
"secrets_store_bindings": [
{
"binding": "TENANT_SECRETS",
"store_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
]
}
// src/handlers/tenant-call.ts
export async function callTenantApi(env: Env, tenantId: string, payload: unknown) {
// Fetch the tenant's stored API key from the Secrets Store
const stored = await env.TENANT_SECRETS.get({
name: `tenant:${tenantId}:api_key`,
});
if (!stored) {
return new Response("Tenant API key not configured", { status: 404 });
}
// Use the secret to call the tenant's downstream API
const response = await fetch(stored.value as string, {
method: "POST",
body: JSON.stringify(payload),
headers: { Authorization: `Bearer ${stored.value}` },
});
return response;
}
The trade-off: Secrets Store reads are not free. Each call is an internal round trip inside Cloudflare's network, typically under 5 ms but not zero. For an API endpoint that fires on every request, I cache the tenant secret in a short-lived KV entry (60-second TTL) and fall back to the Secrets Store on cache miss. The result is per-tenant isolation with sub-millisecond steady-state latency. The full binding reference is in the Secrets Store documentation.
Short-lived OAuth tokens instead of long-lived secrets
The single best secret-management decision I ever made was to stop storing long-lived OAuth refresh tokens for third-party APIs and instead generate short-lived access tokens on demand. The pattern:
// src/lib/oauth.ts
export async function getGoogleAccessToken(env: Env, tenantId: string): Promise<string> {
const cached = await env.KV_CACHE.get(`gtoken:${tenantId}`, { expirationTtl: 3000 });
if (cached) return cached;
const refreshToken = await getTenantRefreshToken(env, tenantId);
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: new URLSearchParams({
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
const { access_token, expires_in } = await response.json<{ access_token: string; expires_in: number }>();
await env.KV_CACHE.put(`gtoken:${tenantId}`, access_token, { expirationTtl: expires_in });
return access_token;
}
The benefits: a leaked KV entry expires in under an hour, the long-lived refresh token is held in the Secrets Store with tight access controls, and rotation is automatic. The cost: one extra HTTPS call per token refresh. For SaaS APIs called on user-driven intervals, this is a fair trade. For high-throughput background jobs, I batch requests inside the token TTL window to amortize the refresh cost.
Compliance, Auditing, and the Failure Modes I Have Actually Hit
Audit logging in practice
Every secret access in a regulated workload — HIPAA, PCI, SOC 2 — needs an audit trail. The Workers runtime gives you request logs natively, but you need to layer an application-level audit log for secret-specific events:
// src/lib/audit.ts
export async function auditSecretAccess(env: Env, event: {
action: "read" | "write" | "delete" | "rotate";
secret: string;
actor: string;
tenantId?: string;
}) {
await env.KV_AUDIT.put(
`audit:${Date.now()}:${crypto.randomUUID()}`,
JSON.stringify({ ...event, ts: new Date().toISOString(), env: env.ENVIRONMENT }),
{ expirationTtl: 60 * 60 * 24 * 365 } // one-year retention
);
}
Pair this with a daily Worker cron that exports the audit log to R2 in Parquet format, and you have a defensible audit trail that satisfies most compliance frameworks. The pattern is documented in the Cloudflare R2 guide.
The committed .env incident
In March 2024 I shipped a .env file with a live Stripe webhook signing key to a public GitHub repo. The commit was public for 47 minutes before I noticed and rotated. Stripe's automated scanning detected it within 6 minutes and emailed me, which is the only reason the damage was contained.
The fixes I now enforce on every project:
.env*and.dev.vars*are in.gitignoreat the repo template level, not added per-project.- A pre-commit hook (
gitleaksortrufflehog) scans every commit for known secret patterns. - CI fails the build if any file matching
.env*is committed. - Stripe webhook handlers always verify the signature against the current key, with a fallback list of the last three keys to handle rotation windows.
These four controls are not expensive. They take an afternoon to set up. They are the difference between a 47-minute incident and a 47-day incident. The OWASP Secrets Management Cheat Sheet has the full list.
When a secret leaks: incident response
The playbook I keep on a sticky note next to my monitor:
- Rotate the secret first, investigate second. Assume the leak is already exploited.
- Check provider logs for the leaked window. Stripe, OpenAI, and most providers expose request logs with API key correlation.
- Check your own logs for use of the leaked credential. Did anyone use it after the leak? Was it the attacker or an automated job?
- Notify affected customers if the secret granted access to their data. PCI and GDPR both have explicit timelines.
- Post-mortem within 48 hours. Blameless, focused on the control gap, not the human error.
The right tools matter here, but the right habits matter more. I rotate every production secret on a 90-day calendar regardless of incident, because the question is not "will this leak" but "when."
Closing: Build the Habit Before the Incident
Secrets management is the unglamorous half of security, and like most unglamorous work it gets done under pressure or not at all. The patterns I have laid out here — typed Env, server-only boundaries, dual-write rotation, per-tenant Secrets Store, audit logging, secret scanning in CI — are not novel. They are the standard toolkit, applied with discipline.
The mistake I keep seeing is treating secret management as a setup task to do once. It is an operational discipline. The moment you stop rotating, stop scanning, stop auditing is the moment you find out your last secret is for sale on a Telegram channel.
TanStack Ship ships every one of these patterns as defaults — typed Env access, secret scanning in CI, Secrets Store integration for per-tenant credentials, and a deploy workflow that makes rotation a one-command operation rather than a Friday-afternoon panic. The pricing page has the details. If you are building SaaS on Cloudflare Workers, TanStack Ship is the fastest way to ship it.
Related reading on TanStack Ship: