<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: RaccoonDev</title>
    <description>The latest articles on DEV Community by RaccoonDev (@mr_raccoon).</description>
    <link>https://dev.to/mr_raccoon</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4232999%2Fae27803a-9937-4b96-9054-675ebe808c06.jpg</url>
      <title>DEV Community: RaccoonDev</title>
      <link>https://dev.to/mr_raccoon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mr_raccoon"/>
    <language>en</language>
    <item>
      <title>Rotate webhook secrets as Stripe</title>
      <dc:creator>RaccoonDev</dc:creator>
      <pubDate>Sun, 08 Nov 2026 05:46:39 +0000</pubDate>
      <link>https://dev.to/mr_raccoon/rotate-webhook-secrets-as-stripe-305c</link>
      <guid>https://dev.to/mr_raccoon/rotate-webhook-secrets-as-stripe-305c</guid>
      <description>&lt;p&gt;When adding a new feature that requires a webhook, you usually think about delivering it, signing it, maybe deduplicating it, and retry logic. But today I want to share one of the best practices for rotating webhook secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The main logic of Stripe's secret rerolling
&lt;/h2&gt;

&lt;p&gt;You can choose your secret expiration either immediately or up to 24 hours later.&lt;br&gt;
If you choose the second option, your OLD secret will stay valid for that period, and all webhook messages will be signed with both secrets.&lt;br&gt;
Maybe you will have lots of questions: is it safe? How can we sign the same message with two secrets? And the reasonable one: what is the purpose of it? Now I will answer all of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  What if the webhook secret is compromised? Will a hacker have access for 24 hours?
&lt;/h2&gt;

&lt;p&gt;Of course not. It is easy to confuse a webhook secret with an API key. So I will remind you of the difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A webhook secret is used by the &lt;strong&gt;customer's&lt;/strong&gt; service to check the author of the message. It is validated on &lt;strong&gt;the client side&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;An API key is used by &lt;strong&gt;our&lt;/strong&gt; server to check user permissions, credits, etc. It is validated on &lt;strong&gt;our side&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a hacker obtains a user's webhook secret, the user rerolls it in our service as soon as they notice it and changes it in project settings. The old one is still valid on our side, but the user has already replaced it in project settings, so a forged message signed with the old secret fails verification on their side.&lt;/p&gt;

&lt;p&gt;Keep in mind that until the user updates their settings, the old secret is still accepted there. So after a confirmed leak, choose immediate expiration. The 24-hour window is for planned rotation, not for incident response.&lt;/p&gt;
&lt;h2&gt;
  
  
  How can we deliver a webhook with multiple signatures?
&lt;/h2&gt;

&lt;p&gt;We generate one signature per active secret and put all of them into a single header. The timestamp is part of the signed string, which protects from replay attacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-Webhook-Signature: t=1731038940,v1=&amp;lt;hmac_with_new_secret&amp;gt;,v1=&amp;lt;hmac_with_old_secret&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sender side: sign with every active secret.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;t=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whsec_old&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whsec_new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whsec_old&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Receiver side: it is enough that any signature matches any secret the client knows.&lt;/p&gt;

&lt;p&gt;So a client that is still on the old secret and a client that has already switched to the new one both pass verification with the same message. After 24 hours we simply stop signing with the old secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the purpose of it?
&lt;/h2&gt;

&lt;p&gt;Imagine you are a DevOps engineer who wants to reroll a secret due to basic safety checks, and you create a new secret in the UI, copy it, go to project settings, paste it, and rerun the project. Overall it will take 10-15 minutes, and during this time some messages will fail verification because of mismatched secrets. They will get a 401 and go to the retry queue, but if the retries run out before the new secret is in place, those messages are lost.&lt;/p&gt;

&lt;p&gt;This chance is small, but when you are building software for 20+ companies, you should remember that this may happen sooner or later. With two signatures, the client can switch secrets at any moment inside the window, and nothing is lost.&lt;/p&gt;

&lt;p&gt;Overall, it is just one of the best practices, and it is not always good for you and your projects, so always think before developing.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>api</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
