hookarmor 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -0
- package/bin/hookarmor.js +132 -0
- package/index.js +11 -0
- package/package.json +52 -0
- package/src/alerter.js +63 -0
- package/src/dispatcher.js +337 -0
- package/src/public/blog/index.html +167 -0
- package/src/public/blog/sqs-webhook-deduplication-trap.html +256 -0
- package/src/public/blog/stripe-webhook-signature-verification-failed.html +284 -0
- package/src/public/index.html +1158 -0
- package/src/public/landing.html +549 -0
- package/src/public/og-preview.png +0 -0
- package/src/public/robots.txt +5 -0
- package/src/public/sitemap.xml +27 -0
- package/src/server.js +365 -0
- package/src/storage.js +266 -0
- package/src/worker.js +62 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>The SQS Deduplication Trap: Why Naive Webhook Queues Silently Drop Payment Retries - HookArmor</title>
|
|
7
|
+
<meta name="description" content="Why writing unique constraint markers before webhook processing causes silent event drops on crash, and how to build zero-loss safe idempotency.">
|
|
8
|
+
<link rel="canonical" href="https://hookarmour.dev/blog/sqs-webhook-deduplication-trap">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
12
|
+
|
|
13
|
+
<!-- OpenGraph / Twitter Cards for Social & Search Snippets -->
|
|
14
|
+
<meta property="og:type" content="article">
|
|
15
|
+
<meta property="og:url" content="https://hookarmour.dev/blog/sqs-webhook-deduplication-trap.html">
|
|
16
|
+
<meta property="og:title" content="The SQS Deduplication Trap: Why Naive Webhook Queues Silently Drop Payment Retries">
|
|
17
|
+
<meta property="og:description" content="Why writing unique constraint markers before webhook processing causes silent event drops on crash, and how to build zero-loss safe idempotency.">
|
|
18
|
+
<meta property="og:site_name" content="HookArmor Engineering Blog">
|
|
19
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
20
|
+
<meta name="twitter:title" content="The SQS Deduplication Trap: Why Naive Webhook Queues Silently Drop Payment Retries">
|
|
21
|
+
<meta name="twitter:description" content="Why writing unique constraint markers before webhook processing causes silent event drops on crash, and how to build zero-loss safe idempotency.">
|
|
22
|
+
|
|
23
|
+
<script type="application/ld+json">
|
|
24
|
+
{
|
|
25
|
+
"@context": "https://schema.org",
|
|
26
|
+
"@type": "TechArticle",
|
|
27
|
+
"headline": "The SQS Deduplication Trap: Why Naive Webhook Queues Silently Drop Payment Retries",
|
|
28
|
+
"description": "An analysis of the database lock race condition where failed workers leave markers behind that fool Stripe into dropping retries.",
|
|
29
|
+
"author": {
|
|
30
|
+
"@type": "Organization",
|
|
31
|
+
"name": "HookArmor"
|
|
32
|
+
},
|
|
33
|
+
"publisher": {
|
|
34
|
+
"@type": "Organization",
|
|
35
|
+
"name": "HookArmor",
|
|
36
|
+
"url": "https://hookarmour.dev"
|
|
37
|
+
},
|
|
38
|
+
"datePublished": "2026-09-23"
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
:root {
|
|
44
|
+
--bg: #070a12;
|
|
45
|
+
--bg-card: #0d1322;
|
|
46
|
+
--border: #1e293b;
|
|
47
|
+
--accent: #3b82f6;
|
|
48
|
+
--text: #f8fafc;
|
|
49
|
+
--text-muted: #94a3b8;
|
|
50
|
+
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
|
51
|
+
--font-mono: 'JetBrains Mono', monospace;
|
|
52
|
+
}
|
|
53
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
54
|
+
body {
|
|
55
|
+
background: var(--bg);
|
|
56
|
+
color: var(--text);
|
|
57
|
+
font-family: var(--font-sans);
|
|
58
|
+
line-height: 1.7;
|
|
59
|
+
padding: 0 1.5rem;
|
|
60
|
+
}
|
|
61
|
+
nav {
|
|
62
|
+
max-width: 820px;
|
|
63
|
+
margin: 0 auto;
|
|
64
|
+
padding: 2rem 0;
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
align-items: center;
|
|
68
|
+
border-bottom: 1px solid var(--border);
|
|
69
|
+
}
|
|
70
|
+
.logo {
|
|
71
|
+
font-weight: 800;
|
|
72
|
+
font-size: 1.25rem;
|
|
73
|
+
color: white;
|
|
74
|
+
text-decoration: none;
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: 0.5rem;
|
|
78
|
+
}
|
|
79
|
+
.nav-links a {
|
|
80
|
+
color: var(--text-muted);
|
|
81
|
+
text-decoration: none;
|
|
82
|
+
font-size: 0.9rem;
|
|
83
|
+
margin-left: 1.5rem;
|
|
84
|
+
}
|
|
85
|
+
article {
|
|
86
|
+
max-width: 820px;
|
|
87
|
+
margin: 3rem auto;
|
|
88
|
+
}
|
|
89
|
+
.meta {
|
|
90
|
+
font-family: var(--font-mono);
|
|
91
|
+
font-size: 0.8rem;
|
|
92
|
+
color: var(--accent);
|
|
93
|
+
margin-bottom: 1rem;
|
|
94
|
+
}
|
|
95
|
+
h1 {
|
|
96
|
+
font-size: 2.3rem;
|
|
97
|
+
font-weight: 800;
|
|
98
|
+
letter-spacing: -0.025em;
|
|
99
|
+
line-height: 1.25;
|
|
100
|
+
margin-bottom: 1.5rem;
|
|
101
|
+
}
|
|
102
|
+
h2 {
|
|
103
|
+
font-size: 1.5rem;
|
|
104
|
+
font-weight: 700;
|
|
105
|
+
margin: 2.5rem 0 1rem;
|
|
106
|
+
color: white;
|
|
107
|
+
border-bottom: 1px solid var(--border);
|
|
108
|
+
padding-bottom: 0.5rem;
|
|
109
|
+
}
|
|
110
|
+
p {
|
|
111
|
+
color: #cbd5e1;
|
|
112
|
+
font-size: 1.05rem;
|
|
113
|
+
margin-bottom: 1.5rem;
|
|
114
|
+
}
|
|
115
|
+
pre {
|
|
116
|
+
background: #0b0f19;
|
|
117
|
+
border: 1px solid var(--border);
|
|
118
|
+
border-radius: 8px;
|
|
119
|
+
padding: 1.25rem;
|
|
120
|
+
overflow-x: auto;
|
|
121
|
+
font-family: var(--font-mono);
|
|
122
|
+
font-size: 0.9rem;
|
|
123
|
+
color: #e2e8f0;
|
|
124
|
+
margin-bottom: 1.5rem;
|
|
125
|
+
line-height: 1.5;
|
|
126
|
+
}
|
|
127
|
+
code {
|
|
128
|
+
font-family: var(--font-mono);
|
|
129
|
+
background: rgba(59, 130, 246, 0.1);
|
|
130
|
+
color: #60a5fa;
|
|
131
|
+
padding: 0.2rem 0.4rem;
|
|
132
|
+
border-radius: 4px;
|
|
133
|
+
font-size: 0.88em;
|
|
134
|
+
}
|
|
135
|
+
pre code {
|
|
136
|
+
background: none;
|
|
137
|
+
color: inherit;
|
|
138
|
+
padding: 0;
|
|
139
|
+
}
|
|
140
|
+
.callout {
|
|
141
|
+
background: rgba(244, 63, 94, 0.08);
|
|
142
|
+
border-left: 4px solid #f43f5e;
|
|
143
|
+
padding: 1.25rem;
|
|
144
|
+
border-radius: 0 8px 8px 0;
|
|
145
|
+
margin-bottom: 1.5rem;
|
|
146
|
+
}
|
|
147
|
+
.callout-success {
|
|
148
|
+
background: rgba(16, 185, 129, 0.08);
|
|
149
|
+
border-left: 4px solid #10b981;
|
|
150
|
+
padding: 1.25rem;
|
|
151
|
+
border-radius: 0 8px 8px 0;
|
|
152
|
+
margin-bottom: 1.5rem;
|
|
153
|
+
}
|
|
154
|
+
.cta-box {
|
|
155
|
+
background: var(--bg-card);
|
|
156
|
+
border: 1px solid var(--border);
|
|
157
|
+
border-radius: 12px;
|
|
158
|
+
padding: 2rem;
|
|
159
|
+
margin-top: 3.5rem;
|
|
160
|
+
text-align: center;
|
|
161
|
+
}
|
|
162
|
+
.cta-btn {
|
|
163
|
+
display: inline-block;
|
|
164
|
+
background: var(--accent);
|
|
165
|
+
color: white;
|
|
166
|
+
text-decoration: none;
|
|
167
|
+
font-weight: 700;
|
|
168
|
+
padding: 0.75rem 1.75rem;
|
|
169
|
+
border-radius: 8px;
|
|
170
|
+
margin-top: 1rem;
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
173
|
+
<meta property="og:image" content="https://hookarmour.dev/og-preview.png">
|
|
174
|
+
<meta property="og:image:width" content="1200">
|
|
175
|
+
<meta property="og:image:height" content="630">
|
|
176
|
+
<meta name="twitter:image" content="https://hookarmour.dev/og-preview.png">
|
|
177
|
+
</head>
|
|
178
|
+
<body>
|
|
179
|
+
<nav>
|
|
180
|
+
<a href="/" class="logo">🛡️ HookArmor</a>
|
|
181
|
+
<div class="nav-links">
|
|
182
|
+
<a href="/blog">Blog</a>
|
|
183
|
+
<a href="/dashboard">Dashboard</a>
|
|
184
|
+
<a href="https://github.com/pkdoddamani/hookarmor" target="_blank">GitHub</a>
|
|
185
|
+
</div>
|
|
186
|
+
</nav>
|
|
187
|
+
|
|
188
|
+
<article>
|
|
189
|
+
<div class="meta">DISTRIBUTED SYSTEMS • IDEMPOTENCY</div>
|
|
190
|
+
<h1>The SQS Deduplication Trap: Why Naive Webhook Queues Silently Drop Payment Retries</h1>
|
|
191
|
+
|
|
192
|
+
<p>Every senior engineer will tell you: <em>"Just put SQS and Lambda in front of your webhooks in 20 lines of code."</em></p>
|
|
193
|
+
|
|
194
|
+
<p>The pattern seems simple enough: an API Gateway endpoint verifies the signature, drops the event onto an Amazon SQS queue, and returns an immediate <code>200 OK</code> to Stripe. A separate consumer Lambda drains the queue and processes the customer subscription.</p>
|
|
195
|
+
|
|
196
|
+
<p>Then you hit the duplicate delivery problem. SQS provides <strong>at-least-once delivery</strong>, meaning network retries or visibility timeouts will inevitably deliver the same invoice twice. Your customers start receiving duplicate billing confirmation emails and double credits.</p>
|
|
197
|
+
|
|
198
|
+
<h2>The "Fix" That Causes Silent Data Loss</h2>
|
|
199
|
+
<p>To prevent duplicates, developers typically add a unique constraint table in Postgres or DynamoDB:</p>
|
|
200
|
+
|
|
201
|
+
<pre><code>// The common "quick" deduplication pattern:
|
|
202
|
+
async function handleWebhook(event) {
|
|
203
|
+
// Try to claim the event upfront
|
|
204
|
+
const res = await db.query(
|
|
205
|
+
"INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING *",
|
|
206
|
+
[event.id]
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (res.rows.length === 0) {
|
|
210
|
+
// Seen before! Skip to prevent duplicate email
|
|
211
|
+
return { status: 'duplicate_skipped' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await provisionSubscription(event);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// Rollback marker so retry can try again!
|
|
218
|
+
await db.query("DELETE FROM processed_events WHERE event_id = $1", [event.id]);
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}</code></pre>
|
|
222
|
+
|
|
223
|
+
<div class="callout">
|
|
224
|
+
<strong>The Fatal Flaw: The Catch Block Never Runs</strong><br>
|
|
225
|
+
Notice the assumption: you assume the <code>catch</code> block will always execute to clean up the marker if something goes wrong. In reality, production serverless environments fail hard:
|
|
226
|
+
<ul>
|
|
227
|
+
<li><strong>Lambda Out-Of-Memory (OOM)</strong>: Process is killed by the runtime immediately. No catch block runs.</li>
|
|
228
|
+
<li><strong>Hard Execution Timeout</strong>: Lambda reaches its 15-minute or API Gateway 29-second ceiling. Process terminated.</li>
|
|
229
|
+
<li><strong>Database Pool Exhaustion</strong>: The downstream query timed out because Postgres ran out of connections. The <code>DELETE</code> statement fails with the exact same connection error!</li>
|
|
230
|
+
</ul>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<h2>The Silent Disappearance of Payment Events</h2>
|
|
234
|
+
<p>When the process dies without executing the <code>DELETE</code> cleanup, the <code>event_id</code> marker remains locked in the database.</p>
|
|
235
|
+
<p>Stripe's exponential backoff kicks in and delivers the webhook again 1 hour later. The worker receives the retry, queries <code>INSERT ... ON CONFLICT DO NOTHING</code>, sees the stale marker from the earlier crashed attempt, concludes it was already successfully handled, and returns <code>200 OK</code>!</p>
|
|
236
|
+
<p><strong>The event is gone forever.</strong> The customer was charged, your database has no subscription record, and no alert was fired.</p>
|
|
237
|
+
|
|
238
|
+
<h2>The Solution: Status-Keyed Ingress Deduplication</h2>
|
|
239
|
+
<p>A resilient webhook gateway must decouple receipt from downstream execution status. In HookArmor, deduplication operates under three strict invariants:</p>
|
|
240
|
+
|
|
241
|
+
<div class="callout-success">
|
|
242
|
+
<ol>
|
|
243
|
+
<li><strong>Only Deduplicate Successful Deliveries</strong>: Deduplication checks only suppress if the existing record in storage has <code>status === 'delivered'</code>. If an event failed, timed out, or is quarantined in the DLQ, re-deliveries are never suppressed.</li>
|
|
244
|
+
<li><strong>Single Logical Event Identity</strong>: An incoming retry for a failed event touches and re-queues the existing row instead of creating duplicate competing rows.</li>
|
|
245
|
+
<li><strong>Destination Concurrency Limiting</strong>: Rather than letting 100 simultaneous invoices flood your database and exhaust the connection pool, HookArmor's ingress buffers the burst and relays requests under a configurable concurrency cap (e.g., 5 concurrent connections).</li>
|
|
246
|
+
</ol>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div class="cta-box">
|
|
250
|
+
<h3>Bulletproof Webhooks Without The AWS Glue Code</h3>
|
|
251
|
+
<p>HookArmor provides safe status deduplication, concurrency protection, and 1-click dead-letter replay in a single self-hostable binary.</p>
|
|
252
|
+
<a href="https://github.com/pkdoddamani/hookarmor" class="cta-btn" target="_blank">Star on GitHub</a>
|
|
253
|
+
</div>
|
|
254
|
+
</article>
|
|
255
|
+
</body>
|
|
256
|
+
</html>
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Why Stripe Webhook Replays Fail Signature Verification (And The Re-Signing Fix) - HookArmor</title>
|
|
7
|
+
<meta name="description" content="Why stripe.webhooks.constructEvent throws 'Timestamp outside the tolerance zone' on replays older than 300s, and how to implement cryptographic re-signing to defeat it.">
|
|
8
|
+
<link rel="canonical" href="https://hookarmour.dev/blog/stripe-webhook-signature-verification-failed">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
12
|
+
|
|
13
|
+
<!-- OpenGraph / Twitter Cards for Social & Search Snippets -->
|
|
14
|
+
<meta property="og:type" content="article">
|
|
15
|
+
<meta property="og:url" content="https://hookarmour.dev/blog/stripe-webhook-signature-verification-failed.html">
|
|
16
|
+
<meta property="og:title" content="Why Stripe Webhook Replays Fail Signature Verification (And The Re-Signing Fix)">
|
|
17
|
+
<meta property="og:description" content="Why stripe.webhooks.constructEvent throws 'Timestamp outside the tolerance zone' on replays older than 300s, and how to implement cryptographic re-signing to defeat it.">
|
|
18
|
+
<meta property="og:site_name" content="HookArmor Engineering Blog">
|
|
19
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
20
|
+
<meta name="twitter:title" content="Why Stripe Webhook Replays Fail Signature Verification (And The Re-Signing Fix)">
|
|
21
|
+
<meta name="twitter:description" content="Why stripe.webhooks.constructEvent throws 'Timestamp outside the tolerance zone' on replays older than 300s, and how to implement cryptographic re-signing to defeat it.">
|
|
22
|
+
|
|
23
|
+
<script type="application/ld+json">
|
|
24
|
+
{
|
|
25
|
+
"@context": "https://schema.org",
|
|
26
|
+
"@type": "TechArticle",
|
|
27
|
+
"headline": "Why Stripe Webhook Replays Fail Signature Verification (And The Re-Signing Fix)",
|
|
28
|
+
"description": "An architectural teardown of Stripe's 300-second webhook signature tolerance and how cryptographic re-signing preserves security without breaking delayed replays.",
|
|
29
|
+
"author": {
|
|
30
|
+
"@type": "Organization",
|
|
31
|
+
"name": "HookArmor"
|
|
32
|
+
},
|
|
33
|
+
"publisher": {
|
|
34
|
+
"@type": "Organization",
|
|
35
|
+
"name": "HookArmor",
|
|
36
|
+
"url": "https://hookarmour.dev"
|
|
37
|
+
},
|
|
38
|
+
"datePublished": "2026-09-23"
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
:root {
|
|
44
|
+
--bg: #070a12;
|
|
45
|
+
--bg-card: #0d1322;
|
|
46
|
+
--border: #1e293b;
|
|
47
|
+
--accent: #3b82f6;
|
|
48
|
+
--text: #f8fafc;
|
|
49
|
+
--text-muted: #94a3b8;
|
|
50
|
+
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
|
51
|
+
--font-mono: 'JetBrains Mono', monospace;
|
|
52
|
+
}
|
|
53
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
54
|
+
body {
|
|
55
|
+
background: var(--bg);
|
|
56
|
+
color: var(--text);
|
|
57
|
+
font-family: var(--font-sans);
|
|
58
|
+
line-height: 1.7;
|
|
59
|
+
padding: 0 1.5rem;
|
|
60
|
+
}
|
|
61
|
+
nav {
|
|
62
|
+
max-width: 820px;
|
|
63
|
+
margin: 0 auto;
|
|
64
|
+
padding: 2rem 0;
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
align-items: center;
|
|
68
|
+
border-bottom: 1px solid var(--border);
|
|
69
|
+
}
|
|
70
|
+
.logo {
|
|
71
|
+
font-weight: 800;
|
|
72
|
+
font-size: 1.25rem;
|
|
73
|
+
color: white;
|
|
74
|
+
text-decoration: none;
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: 0.5rem;
|
|
78
|
+
}
|
|
79
|
+
.nav-links a {
|
|
80
|
+
color: var(--text-muted);
|
|
81
|
+
text-decoration: none;
|
|
82
|
+
font-size: 0.9rem;
|
|
83
|
+
margin-left: 1.5rem;
|
|
84
|
+
}
|
|
85
|
+
article {
|
|
86
|
+
max-width: 820px;
|
|
87
|
+
margin: 3rem auto;
|
|
88
|
+
}
|
|
89
|
+
.meta {
|
|
90
|
+
font-family: var(--font-mono);
|
|
91
|
+
font-size: 0.8rem;
|
|
92
|
+
color: var(--accent);
|
|
93
|
+
margin-bottom: 1rem;
|
|
94
|
+
}
|
|
95
|
+
h1 {
|
|
96
|
+
font-size: 2.3rem;
|
|
97
|
+
font-weight: 800;
|
|
98
|
+
letter-spacing: -0.025em;
|
|
99
|
+
line-height: 1.25;
|
|
100
|
+
margin-bottom: 1.5rem;
|
|
101
|
+
}
|
|
102
|
+
h2 {
|
|
103
|
+
font-size: 1.5rem;
|
|
104
|
+
font-weight: 700;
|
|
105
|
+
margin: 2.5rem 0 1rem;
|
|
106
|
+
color: white;
|
|
107
|
+
border-bottom: 1px solid var(--border);
|
|
108
|
+
padding-bottom: 0.5rem;
|
|
109
|
+
}
|
|
110
|
+
p {
|
|
111
|
+
color: #cbd5e1;
|
|
112
|
+
font-size: 1.05rem;
|
|
113
|
+
margin-bottom: 1.5rem;
|
|
114
|
+
}
|
|
115
|
+
ul, ol {
|
|
116
|
+
margin-left: 1.5rem;
|
|
117
|
+
margin-bottom: 1.5rem;
|
|
118
|
+
color: #cbd5e1;
|
|
119
|
+
}
|
|
120
|
+
li { margin-bottom: 0.5rem; }
|
|
121
|
+
pre {
|
|
122
|
+
background: #0b0f19;
|
|
123
|
+
border: 1px solid var(--border);
|
|
124
|
+
border-radius: 8px;
|
|
125
|
+
padding: 1.25rem;
|
|
126
|
+
overflow-x: auto;
|
|
127
|
+
font-family: var(--font-mono);
|
|
128
|
+
font-size: 0.9rem;
|
|
129
|
+
color: #e2e8f0;
|
|
130
|
+
margin-bottom: 1.5rem;
|
|
131
|
+
line-height: 1.5;
|
|
132
|
+
}
|
|
133
|
+
code {
|
|
134
|
+
font-family: var(--font-mono);
|
|
135
|
+
background: rgba(59, 130, 246, 0.1);
|
|
136
|
+
color: #60a5fa;
|
|
137
|
+
padding: 0.2rem 0.4rem;
|
|
138
|
+
border-radius: 4px;
|
|
139
|
+
font-size: 0.88em;
|
|
140
|
+
}
|
|
141
|
+
pre code {
|
|
142
|
+
background: none;
|
|
143
|
+
color: inherit;
|
|
144
|
+
padding: 0;
|
|
145
|
+
}
|
|
146
|
+
.callout {
|
|
147
|
+
background: rgba(244, 63, 94, 0.08);
|
|
148
|
+
border-left: 4px solid #f43f5e;
|
|
149
|
+
padding: 1.25rem;
|
|
150
|
+
border-radius: 0 8px 8px 0;
|
|
151
|
+
margin-bottom: 1.5rem;
|
|
152
|
+
}
|
|
153
|
+
.callout-success {
|
|
154
|
+
background: rgba(16, 185, 129, 0.08);
|
|
155
|
+
border-left: 4px solid #10b981;
|
|
156
|
+
padding: 1.25rem;
|
|
157
|
+
border-radius: 0 8px 8px 0;
|
|
158
|
+
margin-bottom: 1.5rem;
|
|
159
|
+
}
|
|
160
|
+
.cta-box {
|
|
161
|
+
background: var(--bg-card);
|
|
162
|
+
border: 1px solid var(--border);
|
|
163
|
+
border-radius: 12px;
|
|
164
|
+
padding: 2rem;
|
|
165
|
+
margin-top: 3.5rem;
|
|
166
|
+
text-align: center;
|
|
167
|
+
}
|
|
168
|
+
.cta-btn {
|
|
169
|
+
display: inline-block;
|
|
170
|
+
background: var(--accent);
|
|
171
|
+
color: white;
|
|
172
|
+
text-decoration: none;
|
|
173
|
+
font-weight: 700;
|
|
174
|
+
padding: 0.75rem 1.75rem;
|
|
175
|
+
border-radius: 8px;
|
|
176
|
+
margin-top: 1rem;
|
|
177
|
+
}
|
|
178
|
+
</style>
|
|
179
|
+
<meta property="og:image" content="https://hookarmour.dev/og-preview.png">
|
|
180
|
+
<meta property="og:image:width" content="1200">
|
|
181
|
+
<meta property="og:image:height" content="630">
|
|
182
|
+
<meta name="twitter:image" content="https://hookarmour.dev/og-preview.png">
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<nav>
|
|
186
|
+
<a href="/" class="logo">🛡️ HookArmor</a>
|
|
187
|
+
<div class="nav-links">
|
|
188
|
+
<a href="/blog">Blog</a>
|
|
189
|
+
<a href="/dashboard">Dashboard</a>
|
|
190
|
+
<a href="https://github.com/pkdoddamani/hookarmor" target="_blank">GitHub</a>
|
|
191
|
+
</div>
|
|
192
|
+
</nav>
|
|
193
|
+
|
|
194
|
+
<article>
|
|
195
|
+
<div class="meta">SYSTEMS ARCHITECTURE • STRIPE WEBHOOKS</div>
|
|
196
|
+
<h1>Why Stripe Webhook Replays Fail Signature Verification (And The Re-Signing Fix)</h1>
|
|
197
|
+
|
|
198
|
+
<p>You built a dead-letter queue. Your Next.js app or AWS Lambda function crashed on a Stripe invoice event, your queue caught the failed webhook, and you spent 30 minutes fixing the database bug. You deploy the fix, click "Replay"... and your application immediately throws a 400 Bad Request:</p>
|
|
199
|
+
|
|
200
|
+
<pre><code>Error: Webhook signature verification failed: Timestamp outside the tolerance zone (1800s > 300s)</code></pre>
|
|
201
|
+
|
|
202
|
+
<p>Every developer who attempts to build a webhook retry queue eventually runs headfirst into this wall. Here is why it happens, why common workarounds introduce security vulnerabilities, and how to architect a solution that preserves both security and zero-loss replayability.</p>
|
|
203
|
+
|
|
204
|
+
<h2>1. The 300-Second Signature Window</h2>
|
|
205
|
+
<p>When Stripe dispatches a webhook, it computes an HMAC SHA-256 signature across the current Unix timestamp and the raw JSON payload:</p>
|
|
206
|
+
|
|
207
|
+
<pre><code>v1 = HMAC-SHA256(secret, "${t}.${rawBody}")</code></pre>
|
|
208
|
+
|
|
209
|
+
<p>In the official Stripe Node SDK (and Python, Ruby, Go, and PHP equivalents), <code>stripe.webhooks.constructEvent()</code> does two independent checks:</p>
|
|
210
|
+
<ol>
|
|
211
|
+
<li><strong>Cryptographic authenticity</strong>: Does <code>v1</code> match the HMAC of <code>${t}.${rawBody}</code>?</li>
|
|
212
|
+
<li><strong>Replay protection</strong>: Is <code>Math.abs(Date.now() / 1000 - t) <= 300</code> (5 minutes)?</li>
|
|
213
|
+
</ol>
|
|
214
|
+
|
|
215
|
+
<div class="callout">
|
|
216
|
+
<strong>The Conflict</strong>: Replay attacks and legitimate dead-letter retries look mathematically identical to Stripe's SDK. If your queue holds an event for longer than 300 seconds, standard signature verification will reject it every single time.
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<h2>2. Why The Common Workarounds Fail</h2>
|
|
220
|
+
|
|
221
|
+
<h3>Bad Fix 1: Disabling Signature Verification on Retries</h3>
|
|
222
|
+
<p>Some teams bypass verification if a custom header like <code>x-replayed: true</code> is present. This is disastrous: anyone who discovers your webhook endpoint can forge fake <code>customer.subscription.created</code> events with <code>x-replayed: true</code> and grant themselves free subscriptions.</p>
|
|
223
|
+
|
|
224
|
+
<h3>Bad Fix 2: Bumping the Tolerance Window</h3>
|
|
225
|
+
<pre><code>// Dangerous: Opens a 3-day replay vulnerability
|
|
226
|
+
stripe.webhooks.constructEvent(body, sig, secret, 86400 * 3);</code></pre>
|
|
227
|
+
<p>Increasing the tolerance to 72 hours allows retries to pass, but completely guts replay protection across your entire payment pipeline, allowing attackers to replay stale invoices.</p>
|
|
228
|
+
|
|
229
|
+
<h2>3. The Correct Architecture: Ingress Verify & Outbound Re-Signing</h2>
|
|
230
|
+
<p>The only robust solution that allows your downstream application to keep default 300-second tolerance without altering your production codebase is <strong>Ingress Verification with Fresh Outbound Re-Signing</strong>:</p>
|
|
231
|
+
|
|
232
|
+
<div class="callout-success">
|
|
233
|
+
<strong>The Architecture:</strong>
|
|
234
|
+
<ol>
|
|
235
|
+
<li><strong>At Ingress (<10ms)</strong>: HookArmor receives the webhook from Stripe. It uses your endpoint's signing secret to verify the original signature within the 300s tolerance window. It rejects forgeries upfront and acknowledges Stripe with an immediate <code>200 OK</code>.</li>
|
|
236
|
+
<li><strong>Durable Storage</strong>: The raw uncorrupted payload is written to a persistent Dead-Letter Queue (SQLite in WAL mode).</li>
|
|
237
|
+
<li><strong>At Forward / Replay</strong>: When HookArmor relays the event (whether 2ms later, on retry 1 hour later, or upon manual replay next week), it computes a <strong>fresh timestamp</strong> <code>t=now</code> and generates a mathematically valid <code>v1</code> signature using your secret.</li>
|
|
238
|
+
</ol>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<p>Here is what the outbound re-signing logic looks like under the hood:</p>
|
|
242
|
+
|
|
243
|
+
<pre><code>const crypto = require('crypto');
|
|
244
|
+
|
|
245
|
+
function signStripeHeaders(rawBody, headers, secret) {
|
|
246
|
+
const freshTimestamp = Math.floor(Date.now() / 1000);
|
|
247
|
+
const freshSignature = crypto
|
|
248
|
+
.createHmac('sha256', secret)
|
|
249
|
+
.update(`${freshTimestamp}.${rawBody}`)
|
|
250
|
+
.digest('hex');
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
...headers,
|
|
254
|
+
'stripe-signature': `t=${freshTimestamp},v1=${freshSignature}`
|
|
255
|
+
};
|
|
256
|
+
}</code></pre>
|
|
257
|
+
|
|
258
|
+
<h2>4. The Outcome: Zero Code Changes in Your App</h2>
|
|
259
|
+
<p>Because the forwarded request arrives with a fresh timestamp and a genuine cryptographic HMAC, your backend handler remains completely stock:</p>
|
|
260
|
+
|
|
261
|
+
<pre><code>// In your Next.js App Router route:
|
|
262
|
+
export async function POST(req) {
|
|
263
|
+
const body = await req.text();
|
|
264
|
+
const sig = req.headers.get('stripe-signature');
|
|
265
|
+
|
|
266
|
+
// Works perfectly on first attempt AND on replay 3 weeks later!
|
|
267
|
+
const event = stripe.webhooks.constructEvent(
|
|
268
|
+
body,
|
|
269
|
+
sig,
|
|
270
|
+
process.env.STRIPE_WEBHOOK_SECRET
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
await handleBilling(event);
|
|
274
|
+
return NextResponse.json({ received: true });
|
|
275
|
+
}</code></pre>
|
|
276
|
+
|
|
277
|
+
<div class="cta-box">
|
|
278
|
+
<h3>Never Lose a Stripe Webhook Again</h3>
|
|
279
|
+
<p>HookArmor handles ingress verification, fresh outbound re-signing, background retries, and 1-click dead-letter replay in a single lightweight binary.</p>
|
|
280
|
+
<a href="https://github.com/pkdoddamani/hookarmor" class="cta-btn" target="_blank">View on GitHub (MIT Free)</a>
|
|
281
|
+
</div>
|
|
282
|
+
</article>
|
|
283
|
+
</body>
|
|
284
|
+
</html>
|