Skip to content

fix(#1229): the live webhook verifies with one secret, chosen by deployment - #1230

Merged
TortoiseWolfe merged 1 commit into
mainfrom
fix/webhook-livemode-1229
Sep 23, 2026
Merged

TortoiseWolfe merged 1 commit into
mainfrom
fix/webhook-livemode-1229

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

Closes #1229.

A test-mode signature verified against the live store

The function built a list of both signing secrets and tried each in turn. Both are
configured in production, and both Stripe endpoints point at the same function URL:

mode endpoint signed with
live we_1U5ZAe… STRIPE_WEBHOOK_SECRET_LIVE
test we_1U1geo… STRIPE_WEBHOOK_SECRET

So an event signed with the test secret verified against the live deployment, and the
handler never learned which secret matched. A fabricated payment_intent.succeeded would be
processed against real rows — an order marked paid, with no card and no money.

A test-mode signing secret is ordinarily treated as low-sensitivity: shared with development,
pasted into local .env files, printed in transcripts. That assumption is what this shape
broke.
It broke concretely on 2026-09-23 when this session printed the value into an agent
transcript. The owner is rotating it — but rotation replaces one value and does not fix a
shape, which is why this exists.

The rule

A deployment that has a live secret is the live deployment and accepts only live
signatures. Anything else accepts only test signatures. Exactly one secret is eligible,
decided before any signature is examined.

// before — both eligible, whichever verified won
const WEBHOOK_SECRETS = [LIVE, TEST].filter(Boolean)
for (const secret of WEBHOOK_SECRETS) { try { verify(secret); break } catch {} }

// after — one, chosen by deployment
const SIGNING = resolveSigningSecret({ LIVE, TEST })
verify(SIGNING.choice.secret)
livemodeMismatch(event, SIGNING.choice)

That makes a test-endpoint secret inert against production by construction, rather than by
remembering to keep it secret.

Three judgements worth disagreeing with

The livemode assertion is kept even though selection already picks one secret. The two
answer different questions — which key signed this vs which mode Stripe sent it from — and
#1180 is the standing example of those disagreeing for 31 days while everything looked
configured. It runs before the Supabase client is constructed, so a mismatch cannot reach a
row even in principle.

Not fixed by deleting STRIPE_WEBHOOK_SECRET from Supabase. That works today, and silently
removes test-webhook handling from any environment sharing those secrets with nothing recording
why. The code refuses it; the value's existence stays a deployment concern.

no-secret-provided is still rejected by name (#562). An endpoint configured without a
secret sends the header with exactly that value, so treating it as usable would be a second
route to verifying unsigned traffic.

Where the coverage lives, and why

Nothing in CI executes an Edge Function: tsconfig excludes supabase/, Vitest excludes
supabase/functions/** from discovery, and edge-functions.yml is deliberately not required
because it resolves over the network (#1153 — it cannot run here at all today, failing on
@types/node). So the decision is in a dependency-free resolve.ts and tests/unit/ imports
it directly, the create-order pattern. deno check passes on resolve.ts.

Verification

mutation result
restore the defect — prefer test over live 2 fail
accept the no-secret-provided literal 1 fail
livemode by truthiness instead of typeof 1 fail

Baseline and restored 11/11. type-check clean, 921/921 unit, 1039/1039 script.

Known consequence

Once deployed, the test-mode endpoint stops working against production — deliveries to
we_1U1geo… will 400. That is correct for a live store and is the point of the change, but it
is a capability removed, so it is stated rather than discovered.

Merging does not close the hole. Edge functions deploy separately; a merged function is not
a deployed one.

🤖 Generated with Claude Code

…oyment

The function built a LIST of both signing secrets and tried each in turn. Both
are configured in production and BOTH Stripe endpoints point at the same
function URL:

  live  we_1U5ZAe...  signed with STRIPE_WEBHOOK_SECRET_LIVE
  test  we_1U1geo...  signed with STRIPE_WEBHOOK_SECRET

So an event signed with the TEST secret verified against the LIVE deployment,
and the handler never learned which secret matched. A fabricated
payment_intent.succeeded would be processed against real rows — an order marked
paid with no card and no money.

A test-mode signing secret is ordinarily treated as low-sensitivity: shared with
development, pasted into local .env files, printed in transcripts. That
assumption is exactly what this shape broke. It broke concretely on 2026-09-23,
when this session printed the value into an agent transcript; the owner is
rotating it, but rotation replaces one value and does not fix a shape.

THE RULE. A deployment that HAS a live secret IS the live deployment and accepts
only live signatures; anything else accepts only test signatures. Exactly one
secret is eligible, decided before any signature is examined. That makes a
test-endpoint secret inert against production by construction rather than by
remembering to keep it secret.

The livemode assertion is kept even though the selection already picks one
secret, because the two answer different questions — which key signed this, and
which mode Stripe sent it from — and #1180 is the standing example of those
disagreeing for 31 days while everything looked configured. It runs BEFORE the
Supabase client is constructed, so a mismatch cannot reach a row.

NOT fixed by deleting STRIPE_WEBHOOK_SECRET from Supabase. That works today and
silently removes test-webhook handling from any environment sharing those
secrets, with nothing recording why. The code refuses it; the value's existence
stays a deployment concern.

The decision lives in a dependency-free resolve.ts because nothing in CI
executes an Edge Function — tsconfig excludes supabase/, Vitest excludes
supabase/functions/**, and edge-functions.yml is not required (#1153; it cannot
even run here today, failing on @types/node). tests/unit/ imports it directly,
the create-order pattern.

Verified by mutation, each red on its own:
  restore the defect (prefer test over live)     -> 2 fail
  accept the `no-secret-provided` literal (#562) -> 1 fail
  livemode by truthiness instead of typeof       -> 1 fail
Baseline and restored 11/11. type-check clean, 921/921 unit, 1039/1039 script.
`deno check` passes on resolve.ts.

KNOWN CONSEQUENCE: once deployed, the test-mode endpoint stops working against
production — deliveries to we_1U1geo... will 400. That is correct for a live
store and is the point of the change, but it is a capability removed.

Closes #1229

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TortoiseWolfe
TortoiseWolfe merged commit dd00b2d into main Sep 23, 2026
27 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the fix/webhook-livemode-1229 branch September 23, 2026 06:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The live webhook accepts a test-mode signature, making any test signing secret a live-data credential

1 participant