Skip to content

Feat/implement webhook verification#11

Closed
arxja wants to merge 15 commits into
mainfrom
feat/implement-webhook-verification
Closed

Feat/implement webhook verification#11
arxja wants to merge 15 commits into
mainfrom
feat/implement-webhook-verification

Conversation

@arxja

@arxja arxja commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • Added Stripe-powered subscription checkout for authorized tenant owners.
    • Implemented webhook signature verification and standardized webhook event types.
  • Bug Fixes
    • Improved authentication/authorization for the user profile endpoint: returns clear 401/403 errors and includes membership and tenant details in the response.
  • Documentation
    • Updated the billing guide with a “SaaSify Billing” section and an added TODO for upcoming content.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@arxja, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 28 minutes and 59 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 06cda2a0-d506-4c6b-9603-266c88b51539

📥 Commits

Reviewing files that changed from the base of the PR and between 4fa7cf6 and aed53c9.

📒 Files selected for processing (2)
  • .github/workflows/ci.yml
  • lib/webhook-verification.ts
📝 Walkthrough

Walkthrough

Adds Stripe billing support: a getCurrentUser JWT auth helper, an enriched /api/me endpoint returning user and membership/tenant data, a new POST /api/stripe/create-checkout route that creates subscription Checkout Sessions, a webhook signature verification library supporting Stripe and HMAC providers, Stripe-related environment config fields, and a billing docs placeholder.

Changes

Stripe Billing Infrastructure

Layer / File(s) Summary
Auth helper, Stripe SDK, config, and CI environment
lib/auth/jwt.ts, lib/config/index.ts, lib/stripe/stripe.ts, package.json, .github/workflows/ci.yml
getCurrentUser reads the auth_token cookie and returns a verified JWT payload or null. Stripe environment variables (STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, price IDs) are added to the Zod-validated config schema. The Stripe SDK singleton is initialized with a pinned API version and isStripeTestMode() helper. The stripe npm dependency at ^22.2.1 is added. CI workflow injects Stripe credentials and pricing IDs into the test environment.
Updated /me endpoint
app/api/me/route.ts
GET handler switches from raw JWT extraction to getCurrentUser(), adds a 401 guard, connects to the database, loads the authenticated user and their active membership with tenant data, and returns a structured payload with user (id/email/name) and membership (role plus tenant companyName/subdomain/plan/status/quotas) instead of the prior flat user object.
Stripe Checkout Session API route
app/api/stripe/create-checkout/route.ts, docs/guides/billing.md
New POST route authenticates the caller via getCurrentUser(), verifies active owner membership (403), loads the tenant (404), lazily creates a Stripe customer if missing and persists stripeCustomerId, then creates a subscription-mode Checkout Session with plan metadata and returns the session URL. Billing docs placeholder added.
Webhook types and signature verification library
types/types.ts, lib/webhook-verification.ts
Adds WebhookPayload<T>, WebhookJob, and WebhookEventStatus types. Implements StripeVerifier (parses t/v1 signature parts, ±300s tolerance, HMAC over timestamp.body) and HMACVerifier (HMAC-SHA256, sha256= normalization), both using timing-safe comparison. Exports verifyWebhook and getWebhookSignature as the public API; getVerifier and getWebhookSecret dispatch per provider.

Sequence Diagram

sequenceDiagram
  rect rgba(173, 216, 230, 0.5)
    note over Client,MongoDB: Stripe Billing: /me Endpoint & Checkout
  end
  participant Client as Client
  participant MeRoute as GET /api/me
  participant CheckoutRoute as POST /api/stripe/create-checkout
  participant getCurrentUser as getCurrentUser()
  participant MongoDB as MongoDB
  participant StripeAPI as Stripe API

  Client->>MeRoute: GET /api/me
  MeRoute->>getCurrentUser: verify auth cookie
  getCurrentUser-->>MeRoute: JWTPayload | null (401 if null)
  MeRoute->>MongoDB: find User by id
  MeRoute->>MongoDB: find active Membership
  MeRoute->>MongoDB: find Tenant
  MongoDB-->>MeRoute: user + membership + tenant
  MeRoute-->>Client: { user, membership: { role, tenant } }

  Client->>CheckoutRoute: POST { priceId }
  CheckoutRoute->>getCurrentUser: verify auth cookie
  getCurrentUser-->>CheckoutRoute: JWTPayload | null (401 if null)
  CheckoutRoute->>MongoDB: verify active owner Membership (403 if missing)
  CheckoutRoute->>MongoDB: load Tenant by id (404 if missing)
  CheckoutRoute->>CheckoutRoute: compute planType from priceId
  alt tenant.stripeCustomerId missing
    CheckoutRoute->>StripeAPI: customers.create(email, name, metadata)
    StripeAPI-->>CheckoutRoute: customer.id
    CheckoutRoute->>MongoDB: persist stripeCustomerId to Tenant
  end
  CheckoutRoute->>StripeAPI: checkout.sessions.create(subscription, priceId, metadata)
  StripeAPI-->>CheckoutRoute: session with url
  CheckoutRoute-->>Client: { url: session.url }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Hoppin' through Stripe's garden fair,
A checkout session blooms with care.
Webhooks signed with HMAC might,
Tenant owners pay just right.
The /me route now sings with flair —
Membership and quotas everywhere! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Feat/implement webhook verification' accurately describes the main addition of webhook verification functionality with the new lib/webhook-verification.ts file and supporting infrastructure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/implement-webhook-verification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 10

🧹 Nitpick comments (1)
docs/guides/billing.md (1)

1-3: ⚡ Quick win

Replace placeholder with minimum operator setup steps before release.

This doc currently provides no actionable setup/verification guidance for new billing flows (env vars, webhook setup, checkout route behavior).

I can draft a concise v1 billing runbook (env matrix + Stripe webhook setup + local test checklist) if you want me to open a follow-up issue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/guides/billing.md` around lines 1 - 3, The billing.md file contains only
a placeholder TODO comment with no actionable setup guidance. Replace the TODO
comment with concrete documentation covering the minimum operator setup steps
required before release: document the required environment variables and how to
configure them, provide step-by-step instructions for setting up Stripe webhooks
including webhook endpoint configuration and secret management, describe the
checkout route behavior and expected flow, and include a local testing checklist
to verify the billing integration works correctly end-to-end.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/api/me/route.ts`:
- Around line 19-24: The issue is that tenant data is being fetched and returned
from tenantModel.findById without verifying that the user has an active
membership first, potentially exposing tenant details to unauthorized users. Add
a check after the membershipModel.findOne call to verify the membership exists
and is not null before proceeding to fetch the tenant with tenantModel.findById.
Only return the tenant data in the NextResponse.json response if the membership
check passes, otherwise return an appropriate error response such as a 403
Forbidden or 404 Not Found status.
- Around line 16-21: The code dereferences user._id in the
membershipModel.findOne query without first verifying that the user variable is
not null. After the dashboardUserModel.findById call returns, add a null check
to ensure the user exists before attempting to access user._id. If the user is
null, return an appropriate error response (such as a 404 Not Found or 401
Unauthorized) to prevent a 500 error when a user no longer exists in the
database.

In `@app/api/stripe/create-checkout/route.ts`:
- Around line 67-68: Replace all instances of process.env.NEXT_PUBLIC_APP_URL
with process.env.APP_URL in the Stripe session creation configuration on lines
67-68 where success_url and cancel_url are being set. Since this is a
server-side route file, use the validated server-side APP_URL environment
variable instead of the public-facing NEXT_PUBLIC_APP_URL to ensure the Stripe
redirect URLs are properly constructed even if the public environment variable
is unset.
- Around line 15-16: The priceId extracted from the request body at line 15 is
not validated against allowed values before being passed to
stripe.checkout.sessions.create() at line 65, which creates a security
vulnerability allowing arbitrary price IDs. Add validation logic after
extracting priceId that checks if it matches either NEXT_PUBLIC_PRO_PRICE_ID or
NEXT_PUBLIC_ENTERPRISE_PRICE_ID constants, and throw an error or return early if
the priceId is not in this allowlist. This validation must occur before the
priceId is used in any Stripe API calls or plan type determination logic.

In `@lib/config/index.ts`:
- Around line 19-21: The webhook secrets for GitHub, Slack, and Shopify are
currently defined as required strings in the config schema, which causes startup
failures in environments that only use certain webhook providers (like Stripe).
Make the GITHUB_WEBHOOK_SECRET, SLACK_WEBHOOK_SECRET, and SHOPIFY_WEBHOOK_SECRET
definitions optional by chaining `.optional()` to each schema definition,
allowing applications to boot successfully without providing unused webhook
provider credentials.

In `@lib/stripe/stripe.ts`:
- Around line 2-13: The server-side Stripe initialization in the stripe.ts file
is importing clientConfig from the shared config module, which can cause server
imports to fail if client environment variables are missing, even though only
the server STRIPE_SECRET_KEY is needed. Remove the clientConfig import and
refactor the isStripeTestMode function to determine test mode by checking the
serverConfig STRIPE_SECRET_KEY instead (which starts with "sk_test_" in test
mode), eliminating the dependency on client config being available during server
initialization.

In `@lib/webhook-verification.ts`:
- Line 26: The log.warn call in the webhook verification code is logging the raw
signature value, which is sensitive security material and should not be exposed
in logs. Modify the log.warn statement to redact or hash the signature before
logging it. Instead of passing the signature parameter directly, compute a hash
of the signature or show only a truncated portion of it (e.g., first few
characters followed by ellipsis) to aid in debugging without exposing the full
sensitive value.
- Around line 57-88: The getVerifier function incorrectly routes Slack and
Shopify to the generic HMACVerifier, which cannot satisfy their distinct
requirements. Slack requires signing the concatenated string
"v0:{timestamp}:{rawBody}" and accessing the X-Slack-Request-Timestamp header
for replay protection, while Shopify requires Base64-encoded (not hex) HMAC
digests. Create two new verifier classes, SlackVerifier and ShopifyVerifier,
that implement WebhookVerifier with their provider-specific signing logic. To
support header access, update the WebhookVerifier interface's verify method
signature to accept a headers parameter (or Headers object) in addition to the
existing parameters. Then update the case statements for "slack" and "shopify"
in the getVerifier function to return instances of the new SlackVerifier and
ShopifyVerifier respectively.
- Around line 13-31: The signature parsing reduce function overwrites duplicate
keys, so when Stripe provides multiple v1 signatures during secret rotation,
only the last one is retained. Refactor the reduce logic to collect all v1
signature values into an array instead of a single string. Additionally, the
parseInt(timestamp) call lacks validation, allowing non-numeric timestamps to
return NaN and bypass the timestamp check. Add validation to ensure timestamp is
a valid numeric string using Number.isInteger or similar checks before parsing
it, then update the timestamp comparison logic to work with the validated
numeric timestamp and ensure all collected v1 signatures are verified against
the expected signature.

In `@types/types.ts`:
- Around line 8-24: The WebhookPayload and WebhookJob interfaces use the `any`
type which bypasses type safety. In the WebhookPayload interface, change the
default type parameter from `any` to `unknown` to force consumers to narrow
types explicitly. In the WebhookJob interface, change the `payload` field from
`any` to use a generic type parameter (similar to WebhookPayload's approach) or
at least use `unknown` instead of `any` to maintain type safety throughout the
webhook contract definitions.

---

Nitpick comments:
In `@docs/guides/billing.md`:
- Around line 1-3: The billing.md file contains only a placeholder TODO comment
with no actionable setup guidance. Replace the TODO comment with concrete
documentation covering the minimum operator setup steps required before release:
document the required environment variables and how to configure them, provide
step-by-step instructions for setting up Stripe webhooks including webhook
endpoint configuration and secret management, describe the checkout route
behavior and expected flow, and include a local testing checklist to verify the
billing integration works correctly end-to-end.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a89184be-116e-4bcb-b718-0c9f9a373be5

📥 Commits

Reviewing files that changed from the base of the PR and between 6ec01ea and ca9cc20.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • app/api/me/route.ts
  • app/api/stripe/create-checkout/route.ts
  • docs/guides/billing.md
  • lib/auth/jwt.ts
  • lib/config/index.ts
  • lib/stripe/stripe.ts
  • lib/webhook-verification.ts
  • package.json
  • types/types.ts

Comment thread app/api/me/route.ts
Comment thread app/api/me/route.ts
Comment on lines +15 to +16
const { priceId } = await req.json();
const tenantId = user.tenantId;

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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether checkout route enforces a strict allowlist before Stripe session creation.
rg -nP 'priceId|line_items|NEXT_PUBLIC_(PRO|ENTERPRISE)_PRICE_ID|Invalid priceId' app/api/stripe/create-checkout/route.ts lib/config/index.ts

Repository: arxja/saasify

Length of output: 521


🏁 Script executed:

cat -n app/api/stripe/create-checkout/route.ts

Repository: arxja/saasify

Length of output: 3197


🏁 Script executed:

cat -n lib/config/index.ts | head -50

Repository: arxja/saasify

Length of output: 2031


Reject unapproved priceId values before creating Stripe sessions.

Line 15 accepts priceId directly from the request body without validation. The route only uses it to determine plan type metadata (line 40) but passes it unchecked to stripe.checkout.sessions.create() at line 65. This allows clients to specify arbitrary price IDs, bypassing your intended plan constraints.

Implement an allowlist check against NEXT_PUBLIC_PRO_PRICE_ID and NEXT_PUBLIC_ENTERPRISE_PRICE_ID before creating the session:

Proposed fix
 const { priceId } = await req.json();
+const proPriceId = process.env.NEXT_PUBLIC_PRO_PRICE_ID;
+const enterprisePriceId = process.env.NEXT_PUBLIC_ENTERPRISE_PRICE_ID;
+const allowedPriceIds = [proPriceId, enterprisePriceId].filter(
+  (v): v is string => Boolean(v),
+);
+if (typeof priceId !== "string" || !allowedPriceIds.includes(priceId)) {
+  return NextResponse.json({ error: "Invalid priceId" }, { status: 400 });
+}
@@
 let planType = "pro";
-if (priceId === process.env.NEXT_PUBLIC_ENTERPRISE_PRICE_ID) {
+if (priceId === enterprisePriceId) {
   planType = "enterprise";
 }

Also applies to lines 39-42 (plan type logic) and 65-66 (Stripe API call).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { priceId } = await req.json();
const tenantId = user.tenantId;
const { priceId } = await req.json();
const proPriceId = process.env.NEXT_PUBLIC_PRO_PRICE_ID;
const enterprisePriceId = process.env.NEXT_PUBLIC_ENTERPRISE_PRICE_ID;
const allowedPriceIds = [proPriceId, enterprisePriceId].filter(
(v): v is string => Boolean(v),
);
if (typeof priceId !== "string" || !allowedPriceIds.includes(priceId)) {
return NextResponse.json({ error: "Invalid priceId" }, { status: 400 });
}
const tenantId = user.tenantId;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/stripe/create-checkout/route.ts` around lines 15 - 16, The priceId
extracted from the request body at line 15 is not validated against allowed
values before being passed to stripe.checkout.sessions.create() at line 65,
which creates a security vulnerability allowing arbitrary price IDs. Add
validation logic after extracting priceId that checks if it matches either
NEXT_PUBLIC_PRO_PRICE_ID or NEXT_PUBLIC_ENTERPRISE_PRICE_ID constants, and throw
an error or return early if the priceId is not in this allowlist. This
validation must occur before the priceId is used in any Stripe API calls or plan
type determination logic.

Comment on lines +67 to +68
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/${tenant.subdomain}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/${tenant.subdomain}/settings/billing?canceled=true`,

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the validated server app URL for Stripe redirects.

Line 67-Line 68 use NEXT_PUBLIC_APP_URL, but this config file validates APP_URL on the server side. If NEXT_PUBLIC_APP_URL is unset, Stripe redirect URLs become invalid and session creation fails.

Proposed fix
-      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/${tenant.subdomain}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
-      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/${tenant.subdomain}/settings/billing?canceled=true`,
+      success_url: `${process.env.APP_URL}/${tenant.subdomain}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
+      cancel_url: `${process.env.APP_URL}/${tenant.subdomain}/settings/billing?canceled=true`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/${tenant.subdomain}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/${tenant.subdomain}/settings/billing?canceled=true`,
success_url: `${process.env.APP_URL}/${tenant.subdomain}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/${tenant.subdomain}/settings/billing?canceled=true`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/stripe/create-checkout/route.ts` around lines 67 - 68, Replace all
instances of process.env.NEXT_PUBLIC_APP_URL with process.env.APP_URL in the
Stripe session creation configuration on lines 67-68 where success_url and
cancel_url are being set. Since this is a server-side route file, use the
validated server-side APP_URL environment variable instead of the public-facing
NEXT_PUBLIC_APP_URL to ensure the Stripe redirect URLs are properly constructed
even if the public environment variable is unset.

Comment thread lib/config/index.ts Outdated
Comment thread lib/stripe/stripe.ts
Comment on lines +13 to +31
const signatureParts = signature.split(",").reduce(
(acc, part) => {
const [key, value] = part.split("=");
acc[key] = value;
return acc;
},
{} as Record<string, string>,
);

const timestamp = signatureParts["t"];
const expectedSignature = signatureParts["v1"];

if (!timestamp || !expectedSignature) {
log.warn("Invalid Stripe signature format", { signature });
return false;
}

const currentTimestamp = Math.floor(Date.now() / 1000);
if (Math.abs(currentTimestamp - parseInt(timestamp)) > 300) {

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Can Stripe-Signature include multiple v1 signatures during webhook secret rotation, and what is Stripe's recommended validation approach?

💡 Result:

Yes, the Stripe-Signature header can and will include multiple v1 signatures simultaneously during a webhook secret rotation [1][2]. When you roll a webhook signing secret in the Stripe Dashboard, you can choose to keep the previous secret active for a grace period (up to 24 hours) [1][3]. During this overlap window, Stripe signs every webhook event with both the old and the new secret, resulting in a header that contains multiple v1 signatures (e.g., t=...,v1=sig_old,v1=sig_new) [1][4][5]. Stripe's recommended validation approach is to use their official SDKs, which are designed to handle this scenario automatically [1][6]. Official Validation Recommendations: 1. Use Official Libraries: Always use the Stripe SDK's constructEvent or equivalent verification method [7][6][8]. These libraries are built to parse the header, identify all provided signatures, and verify the payload against your configured secret(s) [9][10]. 2. Support Multiple Secrets: If you are not using an SDK, or to ensure smooth rotation, your verification logic must be able to accept any valid signature provided in the header [4][11][2]. Specifically, you should: - Extract all signatures associated with the v1 scheme from the header [9][10]. - Attempt to verify the payload against each of your currently active signing secrets [11][8]. - Accept the event if at least one combination of secret and provided signature produces a match [9][4][11]. 3. Maintain Proper Rotation Workflow: - Add the new secret to your application environment alongside the existing one [5][8]. - Deploy your code (which must now be capable of verifying with either secret) [4][5][8]. - Trigger a test event in the Stripe Dashboard to confirm that your endpoint successfully verifies the signatures [3][5]. - Once verified, you can finalize the secret rotation in the Stripe Dashboard to expire the old secret, and subsequently remove it from your application configuration [5][8]. Never ignore the signature or attempt to implement custom HMAC verification if you can avoid it; the official SDKs handle constant-time comparisons and other security best practices that protect against common vulnerabilities like timing attacks [11][5][8].

Citations:


🏁 Script executed:

cat -n lib/webhook-verification.ts | head -60

Repository: arxja/saasify

Length of output: 2244


Support multiple v1 signatures and add strict timestamp validation for Stripe webhook verification.

The current implementation (lines 13-20) uses a reduce pattern that overwrites duplicate keys, discarding earlier v1 signatures. During Stripe secret rotation, webhooks are signed with both old and new secrets simultaneously (t=...,v1=sig_old,v1=sig_new), so the code must verify against all provided signatures, not just the last one.

Additionally, line 31 uses parseInt(timestamp) without validation. If timestamp is non-numeric (e.g., "abc"), parseInt returns NaN, and Math.abs(currentTimestamp - NaN) evaluates to NaN, which fails the > 300 check, allowing invalid timestamps to pass verification.

Extract all v1 signatures and validate timestamp format before parsing:

Suggested parsing hardening
-const signatureParts = signature.split(",").reduce(
-  (acc, part) => {
-    const [key, value] = part.split("=");
-    acc[key] = value;
-    return acc;
-  },
-  {} as Record<string, string>,
-);
-
-const timestamp = signatureParts["t"];
-const expectedSignature = signatureParts["v1"];
+const parts = signature.split(",").map((p) => p.trim());
+const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
+const v1Signatures = parts
+  .filter((p) => p.startsWith("v1="))
+  .map((p) => p.slice(3));
 
-if (!timestamp || !expectedSignature) {
+if (!timestamp || v1Signatures.length === 0 || !/^\d+$/.test(timestamp)) {
   log.warn("Invalid Stripe signature format", { signature });
   return false;
 }
 
 const currentTimestamp = Math.floor(Date.now() / 1000);
-if (Math.abs(currentTimestamp - parseInt(timestamp)) > 300) {
+if (Math.abs(currentTimestamp - Number(timestamp)) > 300) {
   ...
 }
 
-return crypto.timingSafeEqual(
-  Buffer.from(hmac),
-  Buffer.from(expectedSignature),
-);
+return v1Signatures.some((sig) =>
+  sig.length === hmac.length &&
+  crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(sig)),
+);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/webhook-verification.ts` around lines 13 - 31, The signature parsing
reduce function overwrites duplicate keys, so when Stripe provides multiple v1
signatures during secret rotation, only the last one is retained. Refactor the
reduce logic to collect all v1 signature values into an array instead of a
single string. Additionally, the parseInt(timestamp) call lacks validation,
allowing non-numeric timestamps to return NaN and bypass the timestamp check.
Add validation to ensure timestamp is a valid numeric string using
Number.isInteger or similar checks before parsing it, then update the timestamp
comparison logic to work with the validated numeric timestamp and ensure all
collected v1 signatures are verified against the expected signature.

const expectedSignature = signatureParts["v1"];

if (!timestamp || !expectedSignature) {
log.warn("Invalid Stripe signature format", { signature });

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid logging raw webhook signatures.

Line 26 logs the full signature header value. Treat signature material as sensitive and redact/hash it before writing logs.

Suggested change
- log.warn("Invalid Stripe signature format", { signature });
+ log.warn("Invalid Stripe signature format", {
+   signaturePreview: signature.slice(0, 8),
+ });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
log.warn("Invalid Stripe signature format", { signature });
log.warn("Invalid Stripe signature format", {
signaturePreview: signature.slice(0, 8),
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/webhook-verification.ts` at line 26, The log.warn call in the webhook
verification code is logging the raw signature value, which is sensitive
security material and should not be exposed in logs. Modify the log.warn
statement to redact or hash the signature before logging it. Instead of passing
the signature parameter directly, compute a hash of the signature or show only a
truncated portion of it (e.g., first few characters followed by ellipsis) to aid
in debugging without exposing the full sensitive value.

Comment on lines +57 to +88
class HMACVerifier implements WebhookVerifier {
verify(rawBody: Buffer, signature: string, secret: string): boolean {
try {
const hmac = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");

const expectedSignature = signature.startsWith("sha256=")
? `sha256=${hmac}`
: hmac;

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} catch (error) {
log.error("HMAC verification failed", error as Error);
return false;
}
}
}

// Factory to get the right verifier
function getVerifier(provider: string): WebhookVerifier {
switch (provider.toLowerCase()) {
case "stripe":
return new StripeVerifier();
case "github":
case "slack":
case "shopify":
return new HMACVerifier();

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is Slack's official request signature verification algorithm, including required headers and replay protection requirements?

💡 Result:

To verify that a request originated from Slack, your application must perform signature verification using your app's unique "Signing Secret," which is available in your Slack app management dashboard [1][2]. Required Headers The verification process requires two specific HTTP headers sent by Slack with every request [1][3]: 1. X-Slack-Request-Timestamp: A Unix timestamp (in seconds) indicating when the request was sent [1][3]. 2. X-Slack-Signature: The HMAC-SHA256 signature, formatted as v0=<hex_digest> [1][3]. Verification Algorithm 1. Replay Protection: Compare the X-Slack-Request-Timestamp header to your server's current time. If the difference is greater than 5 minutes, reject the request as stale to prevent replay attacks [4][5][3]. 2. Base String Construction: Concatenate the version number (currently v0), the timestamp, and the raw request body using a colon as a delimiter: v0:{timestamp}:{raw_request_body} [1][5][3]. 3. HMAC Calculation: Compute an HMAC-SHA256 hash of this base string using your Signing Secret as the key [1][6][3]. 4. Signature Comparison: Prepend the version prefix (v0=) to your calculated hex-encoded hash and compare it to the X-Slack-Signature header [1][5][3]. It is critical to use a timing-safe comparison function to prevent timing attacks [5][7][3]. Important Implementation Notes - Raw Body: You must use the exact raw request body (before any JSON parsing or modification) to generate the hash [7][3]. - Timing-Safe Comparison: Always use a constant-time comparison (e.g., crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python) when checking the signature [4][5][7][3]. - Versioning: The current signature version is v0 [1][5]. Your implementation should check that the signature version in the header matches this expected version [5].

Citations:


🌐 Web query:

What is Shopify's official X-Shopify-Hmac-Sha256 verification format (digest encoding and comparison method)?

💡 Result:

To verify a Shopify webhook, you must compute the HMAC-SHA256 signature of the raw request body using your app's client secret as the key, then compare it to the signature provided in the X-Shopify-Hmac-Sha256 header [1][2]. Verification format and comparison details: - Digest Encoding: The HMAC-SHA256 digest must be encoded as a Base64 string [3][4][5]. Using Hex encoding, which is common for other services, will cause verification to fail [3][5]. - Input Data: Verification must be performed on the raw, unparsed request body [1][3]. Parsing the body into JSON (e.g., using express.json) before verification often modifies whitespace or key ordering, which alters the resulting hash and breaks verification [3][4]. - Comparison Method: The comparison should be performed using a constant-time comparison function (such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python) to prevent timing attacks [1][3][5][2]. To implement this, ensure your middleware captures the raw request body (e.g., as a Buffer) before any body-parsing middleware runs [1][4][6]. If the computed Base64 digest does not match the value in the X-Shopify-Hmac-Sha256 header, the request should be rejected [1][2].

Citations:


🏁 Script executed:

# First, locate and examine the webhook verification file
find . -name "webhook-verification.ts" -type f

Repository: arxja/saasify

Length of output: 87


🏁 Script executed:

# Check the full implementation of the file
if [ -f "lib/webhook-verification.ts" ]; then
  cat -n lib/webhook-verification.ts
fi

Repository: arxja/saasify

Length of output: 5063


🏁 Script executed:

# Search for any other webhook verifier implementations
fd -e ts -e js | xargs grep -l "VerifySlack\|SlackVerifier\|ShopifyVerifier" 2>/dev/null || echo "No provider-specific verifiers found"

Repository: arxja/saasify

Length of output: 94


Do not use the generic HMAC verifier for Slack and Shopify.

Lines 86-88 route Slack and Shopify to HMACVerifier, but this verifier cannot satisfy either provider's requirements:

  • Slack: The verifier only hashes rawBody directly, but Slack requires signing a base string of v0:{timestamp}:{rawBody} and needs access to the X-Slack-Request-Timestamp header for mandatory replay protection (5-minute window). Valid Slack requests will be rejected with the current implementation.

  • Shopify: The verifier uses hex encoding (.digest("hex")), but Shopify requires Base64-encoded HMAC digests. Verification will always fail due to encoding mismatch.

Additionally, the WebhookVerifier interface only accepts a single signature string parameter and cannot pass request headers, making it impossible to implement provider-specific logic that requires header access (like Slack's timestamp).

Implement provider-specific verifiers (SlackVerifier and ShopifyVerifier) that can handle each provider's unique requirements, or update the interface to pass Headers instead of a single signature string.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/webhook-verification.ts` around lines 57 - 88, The getVerifier function
incorrectly routes Slack and Shopify to the generic HMACVerifier, which cannot
satisfy their distinct requirements. Slack requires signing the concatenated
string "v0:{timestamp}:{rawBody}" and accessing the X-Slack-Request-Timestamp
header for replay protection, while Shopify requires Base64-encoded (not hex)
HMAC digests. Create two new verifier classes, SlackVerifier and
ShopifyVerifier, that implement WebhookVerifier with their provider-specific
signing logic. To support header access, update the WebhookVerifier interface's
verify method signature to accept a headers parameter (or Headers object) in
addition to the existing parameters. Then update the case statements for "slack"
and "shopify" in the getVerifier function to return instances of the new
SlackVerifier and ShopifyVerifier respectively.

Comment thread types/types.ts
Comment on lines +8 to +24
export interface WebhookPayload<T = any> {
id: string;
type: string;
data: T;
createdAt: string;
provider: string;
signature: string;
idempotencyKey: string;
}

export interface WebhookJob {
id: string;
eventId: string;
provider: string;
eventType: string;
payload: any;
attempts: number;

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify remaining explicit any usages in TS files
rg -nP --type=ts '\bany\b'

Repository: arxja/saasify

Length of output: 1121


🏁 Script executed:

cat -n types/types.ts | head -40

Repository: arxja/saasify

Length of output: 1101


🏁 Script executed:

rg -nA 5 'export interface WebhookPayload' types/types.ts

Repository: arxja/saasify

Length of output: 198


🏁 Script executed:

rg -nA 15 'export interface WebhookJob' types/types.ts

Repository: arxja/saasify

Length of output: 450


🏁 Script executed:

rg 'WebhookPayload|WebhookJob' --type=ts -g '!types/types.ts' -c

Repository: arxja/saasify

Length of output: 39


🏁 Script executed:

rg 'WebhookPayload|WebhookJob' --type=ts --type=tsx --type=jsx --type=js -l

Repository: arxja/saasify

Length of output: 85


🏁 Script executed:

cat -n tsconfig.json | head -30

Repository: arxja/saasify

Length of output: 912


🏁 Script executed:

find . -name '.eslintrc*' -o -name 'eslint.config.*' | head -5

Repository: arxja/saasify

Length of output: 379


🏁 Script executed:

rg 'WebhookPayload|WebhookJob' -t ts -t js

Repository: arxja/saasify

Length of output: 160


🏁 Script executed:

cat -n eslint.config.mjs | head -80

Repository: arxja/saasify

Length of output: 648


🏁 Script executed:

rg '`@typescript-eslint`' eslint.config.mjs

Repository: arxja/saasify

Length of output: 39


🏁 Script executed:

# Check if there's a rule against 'any' type
rg 'no-explicit-any|no-any' .

Repository: arxja/saasify

Length of output: 39


🏁 Script executed:

# Check if TypeScript strict mode reports issues with explicit any
cat > /tmp/test.ts << 'EOF'
export interface Test<T = any> {
  value: any;
}
EOF
cd /tmp && npx tsc --strict --noEmit test.ts 2>&1 || true

Repository: arxja/saasify

Length of output: 39


🏁 Script executed:

# Check package.json for scripts and dependencies
cat -n package.json | head -40

Repository: arxja/saasify

Length of output: 1446


Use typed payload generics instead of any in webhook contracts.

Line 8 (WebhookPayload<T = any>) and Line 23 (payload: any) bypass type safety. Prefer unknown defaults and generic payload typing to force consumers to narrow types explicitly rather than disabling checks.

Suggested change
-export interface WebhookPayload<T = any> {
+export interface WebhookPayload<T = unknown> {
   id: string;
   type: string;
   data: T;
   createdAt: string;
   provider: string;
   signature: string;
   idempotencyKey: string;
 }
 
-export interface WebhookJob {
+export interface WebhookJob<TPayload = unknown> {
   id: string;
   eventId: string;
   provider: string;
   eventType: string;
-  payload: any;
+  payload: TPayload;
   attempts: number;
   maxAttempts: number;
   nextRetry?: Date;
   status: "pending" | "processing" | "completed" | "failed" | "dead";
   createdAt: Date;
   updatedAt: Date;
   error?: string;
 }
🧰 Tools
🪛 ESLint

[error] 8-8: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 23-23: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types/types.ts` around lines 8 - 24, The WebhookPayload and WebhookJob
interfaces use the `any` type which bypasses type safety. In the WebhookPayload
interface, change the default type parameter from `any` to `unknown` to force
consumers to narrow types explicitly. In the WebhookJob interface, change the
`payload` field from `any` to use a generic type parameter (similar to
WebhookPayload's approach) or at least use `unknown` instead of `any` to
maintain type safety throughout the webhook contract definitions.

Source: Linters/SAST tools

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 30-43: The environment variables block is currently scoped only to
the build step, but the test:ci step runs without them. Since
lib/config/index.ts validates these keys and lib/stripe/stripe.ts reads
serverConfig.STRIPE_SECRET_KEY at import time, tests can fail before assertions
when these modules are imported. Move the entire env block containing
DATABASE_URL, JWT_SECRET, STRIPE_SECRET_KEY, and all other variables from under
the build step to the job level (as jobs.test.env) so both the test:ci and build
steps share access to the same environment variables.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: edfb084b-cfbd-4d28-9cce-467dd8b5777a

📥 Commits

Reviewing files that changed from the base of the PR and between ca9cc20 and 4fa7cf6.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • app/api/me/route.ts
  • lib/config/index.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/api/me/route.ts
  • lib/config/index.ts

Comment thread .github/workflows/ci.yml Outdated
Comment on lines +30 to +43
- run: pnpm run test:ci
- run: pnpm run build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
APP_URL: ${{ secrets.APP_URL }}
NODE_ENV: "development"
LOG_LEVEL: "info"
NEXT_PUBLIC_STRIPE_CHECKOUT_URL: "https://checkout.stripe.com"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
NEXT_PUBLIC_PRO_PRICE_ID: ${{ secrets.NEXT_PUBLIC_PRO_PRICE_ID }}
NEXT_PUBLIC_ENTERPRISE_PRICE_ID: ${{ secrets.NEXT_PUBLIC_ENTERPRISE_PRICE_ID }}

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope required env vars at the test job level, not only the build step.

pnpm run test:ci (Line 30) runs without the env keys added on Lines 32-43. Given lib/config/index.ts validates these keys and lib/stripe/stripe.ts reads serverConfig.STRIPE_SECRET_KEY at import time, tests can fail before assertions when they touch config-dependent modules. Move this env block to jobs.test.env so both test and build share the same contract.

Suggested change
 jobs:
   test:
     runs-on: ubuntu-latest
+    env:
+      DATABASE_URL: ${{ secrets.DATABASE_URL }}
+      JWT_SECRET: ${{ secrets.JWT_SECRET }}
+      STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
+      STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
+      APP_URL: ${{ secrets.APP_URL }}
+      NODE_ENV: "development"
+      LOG_LEVEL: "info"
+      NEXT_PUBLIC_STRIPE_CHECKOUT_URL: "https://checkout.stripe.com"
+      NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
+      NEXT_PUBLIC_PRO_PRICE_ID: ${{ secrets.NEXT_PUBLIC_PRO_PRICE_ID }}
+      NEXT_PUBLIC_ENTERPRISE_PRICE_ID: ${{ secrets.NEXT_PUBLIC_ENTERPRISE_PRICE_ID }}
     permissions:
       contents: read
@@
-      - run: pnpm run build
-        env:
-          DATABASE_URL: ${{ secrets.DATABASE_URL }}
-          JWT_SECRET: ${{ secrets.JWT_SECRET }}
-          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
-          STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
-          APP_URL: ${{ secrets.APP_URL }}
-          NODE_ENV: "development"
-          LOG_LEVEL: "info"
-          NEXT_PUBLIC_STRIPE_CHECKOUT_URL: "https://checkout.stripe.com"
-          NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
-          NEXT_PUBLIC_PRO_PRICE_ID: ${{ secrets.NEXT_PUBLIC_PRO_PRICE_ID }}
-          NEXT_PUBLIC_ENTERPRISE_PRICE_ID: ${{ secrets.NEXT_PUBLIC_ENTERPRISE_PRICE_ID }}
+      - run: pnpm run build
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 30 - 43, The environment variables
block is currently scoped only to the build step, but the test:ci step runs
without them. Since lib/config/index.ts validates these keys and
lib/stripe/stripe.ts reads serverConfig.STRIPE_SECRET_KEY at import time, tests
can fail before assertions when these modules are imported. Move the entire env
block containing DATABASE_URL, JWT_SECRET, STRIPE_SECRET_KEY, and all other
variables from under the build step to the job level (as jobs.test.env) so both
the test:ci and build steps share access to the same environment variables.

@arxja arxja closed this Jun 18, 2026
@arxja arxja deleted the feat/implement-webhook-verification branch June 18, 2026 07:37
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