אימות חתימות Webhook: HMAC, טעויות נפוצות ומימוש נכון
מאת צוות מדיה דיל · 04.08.2026 · API Engineering · 8 דק׳
צלילה טכנית לתוך מנגנון אימות חתימות ב-Webhooks: איך HMAC-SHA256 עובד, למה סדר החישוב קריטי, וכיצד להימנע מהבאגים הנפוצים שהופכים אימות חתימה לחסר משמעות.
כמעט כל ספק Webhooks מתועד מבטיח: "כל בקשה חתומה, כדי לאמת שהיא אכן מגיעה מאיתנו". זו הבטחה חזקה — אבל היא שווה בדיוק כמו איכות המימוש בצד המקבל. ראינו לא מעט מקרים שבהם צוות פיתוח "מימש אימות חתימה", עבר את כל הבדיקות הידניות, ועדיין השאיר פרצה שמאפשרת עקיפה מלאה — כי אימות חתימה נכון תלוי בכמה פרטים עדינים שקל לפספס: איזה חלק מהבקשה בדיוק נחתם, באיזה סדר מרכיבים את המחרוזת, ואיך משווים את התוצאה. המאמר הזה יורד לרמת הפרטים הטכניים האלה.
איך HMAC עובד, בקצרה
HMAC (Hash-based Message Authentication Code) הוא פונקציה שלוקחת שני קלטים — מפתח סודי (Secret) והודעה (Message) — ומייצרת ערך גיבוב (hash) שתלוי בשניהם. התכונה הקריטית היא שבלי לדעת את ה-Secret, אי אפשר לחשב את ה-HMAC הנכון עבור הודעה נתונה, גם אם ההודעה עצמה גלויה לגמרי (וזה המקרה תמיד ב-Webhook — גוף הבקשה עצמו לא סודי). לכן, אם הצד המקבל מחשב HMAC על אותה הודעה עם אותו Secret ומקבל תוצאה זהה לזו שנשלחה בכותרת, יש הוכחה חזקה שהשולח יודע את ה-Secret — כלומר, שהוא באמת הספק המצופה. חשוב להבחין בין HMAC לבין hash רגיל: SHA256 בלי מפתח (סתם `sha256(message)`) לא מספק שום הגנה, כי כל אחד יכול לחשב אותו — התוקף פשוט יחשב hash תואם להודעה שהוא שולח בעצמו. ה-Secret הוא מה שהופך את זה מ"בדיקת שלמות" ל"הוכחת זהות".
HMAC מול חתימה אסימטרית: למה רוב הספקים בוחרים HMAC
יש שתי גישות אפשריות לחתימת Webhooks: HMAC (מפתח סימטרי משותף) וחתימה דיגיטלית אסימטרית (RSA או Ed25519, עם מפתח פרטי אצל השולח ומפתח ציבורי אצל המקבל). היתרון של חתימה אסימטרית הוא שה-Secret הפרטי אף פעם לא עוזב את השרת של השולח — הצד המקבל צריך רק את המפתח הציבורי, שאין בעיה לחשוף. זה מפחית סיכון במקרה שבו יש הרבה צרכנים למפתח אחד. עם זאת, רוב הספקים בוחרים ב-HMAC כי הוא פשוט יותר למימוש, מהיר יותר חישובית, ומספיק לתרחיש הנפוץ שבו יש קשר דו-צדדי מוגדר מראש (ספק אחד, לקוח אחד, Secret ייעודי ביניהם) ולא צורך באימות פומבי כמו בחתימת מסמכים משפטיים. שירותים שדווקא כן זקוקים לאימות ברמת פרסום רחב, כמו ספקי Webhooks שרוצים לאפשר לכל אחד לאמת בלי להחזיק Secret סודי (למשל GitHub עבור אירועי Marketplace מסוימים), משתמשים ב-RSA עם מפתח ציבורי מתפרסם.
מה בדיוק נכנס למחרוזת שנחתמת
כאן נמצא רוב האתגר בפועל. רוב הספקים לא חותמים רק על גוף הבקשה — הם מרכיבים מחרוזת ספציפית שמשלבת timestamp וגוף, מופרדים בתו מוגדר (למשל נקודה). Stripe למשל בונה את המחרוזת החתומה כ-`{timestamp}.{raw_body}`, ומצפה שהצד המקבל יבנה בדיוק את אותה מחרוזת לפני חישוב ה-HMAC. אם מרכיבים את המחרוזת בסדר שונה, עם מפריד שונה, או משמיטים את ה-timestamp, החישוב ייכשל תמיד — לא בגלל בעיית אבטחה, אלא בגלל אי-התאמה טכנית. הפתרון הוא תמיד לקרוא בעיון את תיעוד הספק הספציפי ולא להניח שכל הספקים בונים את המחרוזת באותו אופן; GitHub, למשל, חותם רק על ה-body בלי timestamp נפרד בכותרת (ה-timestamp מגיע כחלק מהגוף עצמו ב-payload), בעוד Shopify משתמש בסכימה דומה אך לא זהה ל-Stripe.
למה חייבים לעבוד עם ה-Raw Body
הבאג הנפוץ ביותר במימוש בפועל: ה-Framework מפענח את גוף הבקשה ל-JSON object לפני שהגיעה ההזדמנות לחשב HMAC, ואז המפתח מנסה "לשחזר" את המחרוזת המקורית באמצעות `JSON.stringify()`. הבעיה: `JSON.stringify` לא בהכרח מייצר בדיוק את אותה מחרוזת בתים שהספק שלח — סדר מפתחות, רווחים, קידוד תווים יוניקוד וייצוג מספרים עשרוניים יכולים להשתנות. התוצאה: HMAC שמחושב על ייצוג "כמעט זהה" אבל לא זהה בדיוק, וההשוואה נכשלת תמיד — גם עבור בקשות לגיטימיות לחלוטין. ברוב ה-Frameworks הפתרון הוא לקבוע route ייעודי ל-Webhook endpoint שמקבל את הגוף כ-buffer גולמי (`raw body`) בלי לעבור פענוח אוטומטי, מחשב עליו את ה-HMAC, ורק אחר כך מפענח אותו ל-JSON לצורך שימוש בלוגיקה. ב-Express זה אומר שימוש ב-`express.raw({ type: 'application/json' })` על ה-route הספציפי הזה בלבד, ולא ב-`express.json()` הגלובלי שמופעל על שאר ה-Application.
למה השוואה רגילה מסוכנת: Timing Attacks
נניח שהמימוש נכון עד לשלב ההשוואה, אבל ההשוואה עצמה נעשית כך:
// לא בטוח!
if (receivedSignature === computedSignature) { ... }
ברוב שפות התכנות, השוואת מחרוזות עוצרת בתו הראשון שבו יש אי-התאמה. זה אומר שהשוואת חתימה שמתחילה נכון אבל נכשלת בתו העשירי לוקחת (באופן מדיד, בסדרי גודל של ננושניות) יותר זמן מהשוואה שנכשלת בתו הראשון. תוקף עם גישה לרשת קרובה מספיק (או אפילו דרך מדידות סטטיסטיות על פני הרבה בקשות) יכול לנצל את ההבדל הזעיר הזה כדי לשחזר את החתימה הנכונה תו אחר תו, ללא ידיעת ה-Secret כלל. זה נשמע תיאורטי, אבל Timing Attacks כאלה הודגמו בפועל שוב ושוב נגד מימושים תמימים. הפתרון: השוואה בזמן קבוע (constant-time comparison) שבודקת את כל התווים תמיד, ללא קשר לתוצאת ביניים:
const crypto = require('crypto');
const a = Buffer.from(receivedSig, 'hex');
const b = Buffer.from(computedSig, 'hex');
const isValid = a.length === b.length && crypto.timingSafeEqual(a, b);
שימו לב לבדיקת האורך המפורשת לפני הקריאה ל-`timingSafeEqual` — הפונקציה עצמה זורקת שגיאה אם האורכים שונים, ולכן חובה לבדוק זאת קודם כדי לא לגרום ל-Exception לא מטופל בזרימה הרגילה.
מנגנון החתימה מנקודת מבט הצד השולח
אם אתם בונים API שמשדר Webhooks ללקוחות שלכם (ולא רק צורכים כאלה מספק חיצוני), האחריות מתהפכת: אתם צריכים לייצר Secret ייחודי לכל לקוח בעת ההרשמה שלו לאינטגרציה, לספק לו ממשק (לוח בקרה) לצפייה ולרוטציה של ה-Secret, ולתעד בבירור איזה אלגוריתם, איזה פורמט מחרוזת ואיזו כותרת HTTP אתם משתמשים. חתימה שלא מתועדת היטב, או מתועדת בצורה מעורפלת, גורמת לכל אינטגרטור חיצוני "לגלות מחדש" את הפרוטוקול בעצמו — מה שמגדיל דרמטית את הסיכוי לבאגי מימוש כמו אלה שתוארו למעלה, אצל כל אחד מהלקוחות שלכם בנפרד. תיעוד טוב כולל דוגמת קוד מלאה בכמה שפות נפוצות, ולא רק תיאור מילולי של האלגוריתם.
ריבוי Secrets ורוטציה
ספקים בוגרים תומכים בכמה Signing Secrets פעילים בו-זמנית, בדיוק כדי לאפשר רוטציה בלי downtime. המימוש בצד המקבל צריך לתמוך בכך גם הוא: לשמור רשימת Secrets תקפים (לא רק אחד), ולנסות אימות מול כל אחד מהם עד שאחד מצליח. זה מאפשר תהליך רוטציה חלק — מוסיפים Secret חדש, מוודאים שהמימוש תומך בשניהם, מעדכנים אצל הספק להתחיל לחתום גם עם החדש, ורק אחרי תקופת מעבר בטוחה מסירים את הישן. מימוש שתומך רק ב-Secret בודד הופך כל רוטציה לאירוע בסיכון גבוה של downtime.
בדיקות אוטומטיות למנגנון אימות חתימה
מנגנון אימות חתימה הוא בדיוק סוג הקוד שכדאי לכסות בבדיקות אוטומטיות ייעודיות, כי הוא נוטה להישבר בשקט כתוצאה משינויים לא קשורים לכאורה — למשל שדרוג גרסת Framework שמשנה את אופן הטיפול ב-Middleware. בדיקות מומלצות: חתימה תקינה על body תקין מאומתת בהצלחה; אותו body עם חתימה שגויה נדחה; body שהשתנה (אפילו תו אחד) עם חתימה מקורית נדחה; timestamp ישן מדי נדחה גם עם חתימה תקינה; ו-body ריק או מעוות לא גורם לקריסת השרת אלא לדחייה מבוקרת. בדיקה נוספת שכדאי להוסיף באופן ספציפי: וידוא שזמן התגובה של דחיית חתימה שגויה לא תלוי באורך ההתאמה החלקית — כלומר, מדידת ביצועים שמוודאת שההשוואה אכן constant-time ולא רק "נראית ככה" בקוד.
גרסאות חתימה ותאימות לאחור
ספקים שמשנים את אלגוריתם החתימה שלהם (למשל מ-SHA1 ל-SHA256) שולחים לעיתים כמה חתימות במקביל, מסומנות בגרסה (`v0=`, `v1=`), כדי לאפשר ללקוחות לעבור בקצב שלהם. מימוש שמניח שיש רק פורמט חתימה אחד עלול להישבר בשקט כשהספק מוסיף גרסה חדשה, או גרוע יותר — לאמת רק מול הגרסה הישנה והחלשה יותר בטעות. חשוב תמיד לבדוק את קידומת הגרסה בכותרת ולוודא שמאמתים מול הגרסה החזקה ביותר הזמינה, לא הראשונה שמופיעה במחרוזת.
טעויות נפוצות שראינו בפרודקשן
מעבר לבאג ה-raw body שכבר תואר, הטעויות השכיחות הן: שימוש ב-`==` בשפות שממירות טיפוסים אוטומטית, מה שיוצר התנהגות בלתי צפויה כשההשוואה בין ערכים ממקורות שונים; לוג של ה-Secret עצמו לצורכי Debug ("בואו נראה למה זה לא תואם") שנשאר בקוד ומגיע ל-Production; והיעדר בדיקה על אורך הקלט לפני עיבוד, מה שחושף לתקיפות DoS פשוטות דרך שליחת payloads ענקיים שמעבדים HMAC על מגה-בייטים של נתונים חסרי טעם. טעות נוספת, עדינה יותר: קידוד הפלט של ה-HMAC בפורמט לא עקבי — חלק מהמימושים משווים מחרוזת hex לעומת מחרוזת base64, שתי קידוד תקינים לאותו ערך בינארי אבל לעולם לא יהיו שווים כמחרוזות. חובה לוודא שגם החישוב וגם ההשוואה משתמשים באותו קידוד שהספק מציין בתיעוד, ולא להניח שקידוד ברירת המחדל של ספריית ההצפנה תואם למה שהספק שולח.
תלות בספריית ההצפנה של השפה, לא במימוש עצמאי
פיתוי נפוץ בקרב מפתחים שרוצים "להבין את זה עד הסוף" הוא לממש את אלגוריתם ה-HMAC בעצמם במקום להשתמש בספריית ההצפנה הסטנדרטית של השפה. זו טעות כמעט תמיד. מימושים עצמאיים של פרימיטיבים קריפטוגרפיים נוטים להכיל בדיוק את סוג הבאגים העדינים שתוארו כאן — במיוחד סביב עמידות ל-Timing Attacks, שדורשת תשומת לב ברמת החומרה שספריות מבוססות כבר טיפלו בה. `crypto` המובנה ב-Node.js, `hmac` בפייתון, ו-`crypto/hmac` בגו הם כולם מימושים בדוקים ומתוחזקים על ידי קהילות גדולות; אין שום יתרון אמיתי בכתיבה מחדש, והסיכון עולה משמעותית על התועלת החינוכית.
מעבר לחתימה: מה עוד כדאי לבדוק
אימות חתימה הוא תנאי הכרחי אבל לא מספיק. מומלץ לצרף לו בדיקת Content-Type תואם (דחיית בקשות שלא מוצהרות כ-`application/json` אם זה הפורמט הצפוי), הגבלת גודל body מקסימלי לפני שמתחילים בכלל בחישוב ה-HMAC (כדי למנוע בזבוז משאבים על payloads חריגים בגודלם), ורישום מקור ה-IP של הבקשה לצורך ניתוח פורנזי מאוחר יותר גם אם לא חוסמים לפיו. שילוב הבדיקות האלה יוצר הגנה בעומק (Defense in Depth) שבה אימות החתימה הוא השכבה הקריטית ביותר, אבל לא היחידה.
סיכום
אימות חתימה נכון הוא לא רק "לחשב HMAC ולהשוות" — הוא תלוי בעבודה עם ה-raw body המדויק, בהרכבת מחרוזת החתימה בדיוק לפי מפרט הספק, בהשוואה בזמן קבוע, ובתמיכה ברוטציית Secrets. כל אחד מהפרטים האלה, אם מפוספס, הופך את שכבת האבטחה לחסרת ערך בפועל גם אם היא "עובדת" בבדיקות בסיסיות. השקעה של שעה-שעתיים בקריאה מדוקדקת של תיעוד הספק הספציפי חוסכת חשיפה אמיתית שקשה לזהות בבדיקות ידניות רגילות.
תגיות: HMAC · Webhook Signature · SHA256 · Cryptography · API Security · Timing Attack