Skip to content

fix(stripe): accept any matching webhook v1 signature#480

Open
jsdavid278-cyber wants to merge 2 commits into
profullstack:masterfrom
jsdavid278-cyber:fix/stripe-multiple-v1-signatures
Open

fix(stripe): accept any matching webhook v1 signature#480
jsdavid278-cyber wants to merge 2 commits into
profullstack:masterfrom
jsdavid278-cyber:fix/stripe-multiple-v1-signatures

Conversation

@jsdavid278-cyber
Copy link
Copy Markdown

Summary

  • preserve all v1 values from Stripe-Signature instead of collapsing duplicate keys
  • accept the webhook when any provided v1 signature matches the expected HMAC
  • add a regression test for rotated/multiple v1 signatures

Why

Stripe can include multiple v1 signatures during webhook secret rotation. The previous Object.fromEntries parsing only kept the last v1 value, so a valid signature could be ignored if another v1 appeared later.

Testing

  • Not run locally: this Codex environment does not have npm/corepack/pnpm installed.

uGig gig: abd6b2a0-e728-48cf-a46f-f99e419ed94e

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 30, 2026

Greptile Summary

This PR fixes webhook signature validation to correctly handle Stripe's key-rotation scenario where the Stripe-Signature header contains multiple v1 values. The old Object.fromEntries parsing silently discarded all but the last v1, so a valid early signature paired with an invalid later one would be rejected.

  • index.ts: verifyStripeSignature now collects all v1 values into an array and accepts the webhook as soon as any one of them passes timingSafeEqual against the computed HMAC, preserving constant-time comparison for each candidate.
  • index.test.ts: Adds a regression test that passes a header with one valid and one all-zero v1 signature and expects acceptance.

Confidence Score: 4/5

Safe to merge — the implementation correctly collects all v1 signatures and accepts the first that passes a timing-safe comparison, which is the right approach for Stripe rotation headers.

The core logic change in verifyStripeSignature is sound: all v1 values are preserved and each is compared with timingSafeEqual, so neither the rotation case nor the single-signature case regresses. The only gap is that the new test only exercises one of the two possible orderings of valid/invalid signatures, leaving a minor blind spot in coverage.

packages/payments/stripe/src/index.test.ts — the regression test could be strengthened by also asserting the inverse ordering (valid signature last).

Important Files Changed

Filename Overview
packages/payments/stripe/src/index.ts Replaces Object.fromEntries parsing (which collapsed duplicate v1 keys) with a loop that collects all v1 values; accepts the webhook if any collected signature passes timingSafeEqual comparison.
packages/payments/stripe/src/index.test.ts Adds one regression test for multiple v1 signatures (valid first, invalid last); covers the order that broke the old code, but not the inverse order.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Stripe-Signature header] --> B[Split on comma]
    B --> C{For each part, split on '='}
    C --> D{key === 't'?}
    D -- Yes --> E[Set timestamp]
    D -- No --> F{key === 'v1' and value non-empty?}
    F -- Yes --> G[Append to signatures array]
    F -- No --> C
    E --> C
    G --> C
    C -- Done --> H{timestamp present AND signatures non-empty?}
    H -- No --> I[throw: missing t or v1]
    H -- Yes --> J[Compute expected HMAC with timestamp + rawBody]
    J --> K[Convert expected to Buffer]
    K --> L{For each collected signature}
    L --> M[Convert signature to Buffer]
    M --> N{lengths equal AND timingSafeEqual?}
    N -- Yes --> O[return — webhook accepted]
    N -- No --> L
    L -- Exhausted --> P[throw: Invalid signature]
Loading

Reviews (1): Last reviewed commit: "test(stripe): cover multiple webhook sig..." | Re-trigger Greptile

Comment on lines +99 to +115
it('accepts any matching Stripe v1 signature when multiple are present', async () => {
const raw = JSON.stringify({ type: 'checkout.session.completed' });
const secret = 'whsec_test';
const timestamp = '1700000000';
const signature = createHmac('sha256', secret)
.update(`${timestamp}.${raw}`)
.digest('hex');

const webhook = await adapter.verifyWebhook(
ctx({ STRIPE_WEBHOOK_SECRET: secret }),
raw,
`t=${timestamp},v1=${signature},v1=${'0'.repeat(64)}`,
{},
);

expect(webhook.type).toBe('checkout.session.completed');
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test only covers one signature ordering

The regression test places the valid signature first and the invalid one last (v1=valid,v1=zeroes). This is the order the old Object.fromEntries code failed on (it kept the last v1, which was invalid). However, the symmetric case — v1=zeroes,v1=valid — is equally important to verify: Stripe may send the new-secret signature last during rotation, so confirming that ordering also passes guards against any future off-by-one regression in the loop.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

1 participant