Skip to content
Bloga Dönün
Güvenlik

Webhook imza doğrulama hatası: nedenleri ve çözümleri

Webhook imza doğrulama hatası mı alıyorsunuz? Genelde ham gövde, digest kodlaması ya da zaman damgası öneki sorunludur. Ücretsiz HMAC aracıyla ayıklayın.

15 dakika okuma

Webhook imza doğrulama hatası mı? Nedenini bulun

webhook signature verification failed hatası tek bir şeyi söyler: kodunuzun hesapladığı digest, isteğin header’ında gelen digest’e eşit değil. İzinlerle ya da bir sürenin dolmasıyla ilgisi yok; neredeyse hiçbir zaman da sağlayıcının SDK’sındaki bir hata değil. Sağlayıcının hash’lediği baytlar ile sizin hash’lediğiniz baytlar farklıdır.

Sonucu dört girdi belirler: hangi baytların imzalandığı, hangi anahtar baytlarının kullanıldığı, hangi hash algoritmasının çalıştığı ve karşılaştırmayı hangi metin kodlamasında yaptığınız. Bunlardan birini yanlış alın, hata birebir aynı görünür. Hangisinden geldiğine dair ipucu vermediği için mesajı daha dikkatli okumanın faydası yok; girdi uzayını daraltmak gerekir.

Başlangıç dalınızı seçin:

İmza uyuşmuyor mu? Üç dal:
├─ Framework JSON'u siz görmeden parse etti mi?              → Bölüm 3
├─ Header değeri bir önek taşıyor mu, ya da Base64 gibi mi?  → Bölüm 4
└─ Sağlayıcının header'ında bir zaman damgası var mı?        → Bölüm 2

Aşağıdaki her bölüm, kendi payload’unuz üzerinde çalıştırabileceğiniz bir şeyle bitiyor.

1. Bir imza uyuşmazlığı size gerçekte ne söyler

Doğrulama, iki bayt dizisini karşılaştırmaktan ibarettir. Başarısız olduğunda dört şeyden biri yanlıştır ve dördü birbirini etkilemez.

Hangi baytlar imzalandı. Sağlayıcı belirli bir bayt dizisini hash’ledi. Bu, isteğin gövdesi tek başına olabilir; gövdenin önüne yapıştırılmış bir zaman damgası da olabilir. Framework JSON’u parse edip size bir nesne verdiyse o baytlar artık elinizde değildir ve onları güvenilir biçimde yeniden kuramazsınız. Bu Bölüm 3’ün konusu ve açık ara en yaygın neden.

Hangi anahtar baytları kullanıldı. Aynı gizli anahtar karakter dizisi UTF-8 metin, hex ya da Base64 olarak yorumlanabilir ve her okuma farklı bir anahtar üretir. Yapılandırma yükleyicisinin sakladığı fazladan bir satır sonu da öyle. Bu boyutta ikinci bir arıza saklı: gizli anahtar, doğru anahtarın yanlış okunması değil, baştan yanlış anahtar olabilir. Bölüm 6 bunu anlatıyor.

Karşılaştırmayı hangi kodlamada yaptınız. SHA-256 için bir digest 32 ham bayttır. Hex ve Base64, bu aynı baytları metin olarak yazmanın iki yoludur ve asla birbirine benzemezler. Birini diğeriyle karşılaştırırsanız, alttaki baytlar uyuşuyor olsa bile kalıcı bir hmac signature mismatch alırsınız.

Hangi hash algoritması çalıştı. Sağlayıcıların çoğu SHA-256 kullanır ve bunu belgeler, o yüzden bu boyut genelde size hiçbir şeye mal olmaz. Bilmeye değer istisna GitHub: her teslimat X-Hub-Signature-256 (HMAC-SHA256) ile birlikte X-Hub-Signature (HMAC-SHA1) taşır; GitHub’ın kendi dokümantasyonu SHA-1 header’ı için “yalnızca geriye dönük uyumluluk için dahil edilmiştir” diyor ve 256 sürümünü öneriyor. Yanlış olanı okursanız bunu baytlardan önce uzunluk ele verir. Bölüm 2’deki gövde aynı gizli anahtarla SHA-1 altında imzalandığında sha1=ba2954d180839d8170b08b32cd38483775aaae96 çıkar: SHA-256 digest’inin 64 karakterine karşılık 40 hex karakter.

Hata ayıklarken bu dördünü ayrı tutun. Bir boyutu izole etmenin en hızlı yolu, digest’i uygulamanızın dışında ve sizin kontrol ettiğiniz girdilerden hesaplamaktır: bir gövde ile bir gizli anahtarı HMAC üretici aracına yapıştırın ve ne çıktığına bakın. Araç tamamen tarayıcınızın içinde çalışır ve gizli anahtar sayfadan dışarı çıkmaz, yani üretimdeki bir imzalama anahtarını oraya yapıştırmak güvenlidir. HMAC da tıpkı düz bir SHA-256 özet üretici gibi aynı SHA-256 primitifini çalıştırır, tek fark gizli anahtarınızla anahtarlanmış olmasıdır. Yani sağlayıcının değerini elle yeniden üretebiliyorsanız kriptografi sağlamdır ve hata istek işleyişinizdedir.

2. Dört büyük sağlayıcı neyi imzalar

Entegrasyonların çoğunu batıran varsayım şudur: her sağlayıcı yalnızca isteğin gövdesini imzalar. En büyük dördünden ikisi bunu yapmıyor. Aşağıdaki tablo, her birinin neyi hash’lediğini sağlayıcıların güncel dokümantasyonuna göre veriyor:

SağlayıcıHeaderİmzalanan dizeKodlamaDeğer önekiGizli anahtarZaman damgası toleransı
StripeStripe-Signature{timestamp} + . + rawBodyhext=…,v1=…,v0=…endpoint imzalama gizli anahtarı (whsec_ öneki)5 dakika (300 saniye)
GitHubX-Hub-Signature-256rawBody (önek yok)hexsha256=webhook gizli token’ıyok (zaman damgası gönderilmez)
SlackX-Slack-Signature + X-Slack-Request-Timestampv0: + {timestamp} + : + rawBodyhexv0=imzalama gizli anahtarı5 dakika
ShopifyX-Shopify-Hmac-SHA256rawBodybase64yokuygulamanın client secret’ı (ayrı bir webhook gizli anahtarı değil)yok

Bu dördü, birbirinden bağımsız üç ekseni kapsıyor. İmzalanan dize ya gövdenin kendisidir ya da bir zaman damgası birleştirmesidir; ayırıcı bile farklı: Stripe . kullanır, Slack ise :. Kodlama üçünde hex, birinde Base64. Gizli anahtar üçünde webhook’a ayrılmış bir kimlik bilgisinden gelir, Shopify’da ise uygulamanın client secret’ından. İnsanların en sık yanlış anladığı ayrıntı bu, çünkü yönetim arayüzünde “webhook” etiketli bir alan var ve aradığınız şey o değil.

Farkları somutlaştırmak için, tek bir gizli anahtarla dört ayrı biçimde imzalanmış tek bir gövde:

body   : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts     : 1700000000
BiçimDeğer
GitHub tarzısha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
Shopify tarzıCd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=
Stripe tarzıt=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375
Slack tarzıv0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c

İlk iki satırı birlikte okuyun, çünkü ikisi de aynı 32 baytlık digest’in iki farklı yazımı. Altmış dört hex karakter ya da dolgu dahil kırk dört Base64 karakteri. İki karakter dizisinin eşit olduğuna dair hiçbir işaret yok. Kodlamalar arası karşılaştırma da bu yüzden, akıl edebileceğiniz her “ama gizli anahtar doğru” kontrolünden sağ çıkan bir uyuşmazlık üretir.

Son iki satır ise iddianın diğer yarısını kanıtlıyor. Aynı gövde, aynı gizli anahtar, aynı algoritma; ama iki digest’in hiçbiri GitHub’dakine benzemiyor, çünkü hash’lenen dize artık bir zaman damgasıyla başlıyor. stripe webhook signature verification failed bildirimlerinin çoğu bu satıra iniyor: kod gövdeyi tek başına hash’lemiş ve t değeriyle noktayı hiç öne eklememiş. Dördünü de HMAC üretici aracında yeniden üretin: yalnızca mesaj alanını düzenleyip çıktı biçimini değiştirmeniz yeterli.

Zaman damgası kolonunun pratik bir sonucu var: Stripe ya da Slack digest’i yalnızca birkaç dakika geçerlidir, yani bugün bir imza yakalayıp yarın bir testte yeniden oynatamazsınız. GitHub ve Shopify imzaları ise süresiz olarak sabittir; bu da hata ayıklamayı kolaylaştırır, ama replay korumasını kendinizin düşünmesi gerekir.

Tablodaki dördü küresel sağlayıcılar. Türkiye’de çalışıyorsanız PayTR gibi yerel sağlayıcılar da bildirimlerini HMAC ile imzalar, yani aynı dört boyut orada da geçerli. Hangi dizeyi imzaladıklarını ve digest’i hangi kodlamayla gönderdiklerini kendi dokümanlarından doğrulayın.

3. Ham gövde problemi

webhook signature verification failed bildirimlerinin çoğunun izi bu bölüme, yani gövdeyi sizden önce okuyan katmana çıkıyor.

Framework’ünüz baytları çoktan yok etti

Web framework’leri sizi parse etmekten kurtarmak için yapıldı. İmza doğrulamasını bozan şey tam olarak bu kolaylık, çünkü handler’ınız çalışmaya başladığında özgün baytlar çoktan gitmiştir.

express.json() istek akışını okur, parse eder ve req.body’yi bir JavaScript nesnesiyle değiştirir. Akış tüketilmiştir, ikinci kez okunamaz. FastAPI’de bir Pydantic modeli ya da dict tipinde bir gövde parametresi tanımlamak, framework’ün siz fonksiyona girmeden önce okuyup parse etmesi demektir. Rails, controller aksiyonunuzdan önce çalışan bir middleware üzerinden params’ı JSON gövdesinden doldurur. Spring’in Jackson dönüştürücüsü gövdeyi DTO sınıfınıza çevirir ve varsayılan olarak alttaki HttpServletRequest girdi akışı yalnızca bir kez okunabilir.

Buradaki hiçbir şey hata değil. Her biri, yapılandırıldığı işi yapıyor. Sorun şu: imza baytları kapsar, nesne bayt değildir ve nesneyi tekrar bayta çevirmek, sağlayıcının uyguladığı işlemden farklı bir işlemdir.

Yeniden serileştirmek neden bazen işe yarıyor, tuzak da burada

Alışılmış tavsiye, yeniden serileştirmenin baytları değiştirdiğini söyler. Anlatının yarısı eksik: bazen hiçbir şeyi değiştirmez, teşhisi zorlaştıran da bu.

JSON.stringify(JSON.parse(body)) === body karşılaştırmasının farklı payload biçimlerindeki sonucu:

Payload biçimiRound-trip sonrası baytlarDeğişim
{"id":42,"event":"user.created"}birebir aynıyok — yerel testlerin geçmesinin nedeni de bu
{"amount":1.0}değişti{"amount":1}
{"n":1e3}değişti{"n":1000}
{"id":12345678901234567890}değişti{"id":12345678901234567000} (hassasiyet kaybı)
{"name":"caf\u00e9"}değişti{"name":"café"} (6 bayt 2 bayta düşer)
{"a":1}\ndeğiştisondaki satır sonu yutuldu
{ "a" : 1 }değiştiiçteki boşluklar yutuldu
{"v":-0.0}değişti{"v":0}
{"p":0.1000000000000000055511151231257827}değişti{"p":0.1}

İlk satıra bakın. Bir tam sayı ve kısa bir ASCII karakter dizisi taşıyan düz bir nesne, round-trip’ten bayt bayt aynı çıkar; dolayısıyla parse edip yeniden serileştiren bir doğrulayıcı, böyle bir fixture’a karşı yazdığınız her testi geçer. Sonra dağıtımı yaparsınız ve 1.0 biçiminde bir para tutarı, 2^53’ün üzerinde bir ID ya da aksanlı bir müşteri adı taşıyan ilk payload patlar. Hepsi patlamaz, sadece bunlar patlar.

“Yerelde çalışıyor, üretimde arada bir 401” durumunun arkasındaki mekanizma budur ve her seferinde patlayan bir doğrulayıcıdan çok daha kötüdür. Her zaman patlayan bir doğrulayıcıyı bir saatte düzeltirsiniz. Olayların %3’ünde patlayan biri ise sağlayıcının üstüne yıkılır: ekip isteği yeniden dener, konuyu üst kademeye taşır ve haftalarca bununla yaşar. Hata oranınız kesin olarak sıfır ile yüzde yüz arasında bir yerdeyse, ilk bakılacak yer bu tablo.

Anahtar sırası, herkesin beklediği ama pratikte en az olası olan nedendir, çünkü JSON.parse karakter dizisi anahtarlar için ekleme sırasını korur. Asıl suçlular sayılar ve boşluklar.

Her framework’te ham gövdeyi almak

Express’te rotaya özgü parser’ı global JSON parser’ından önce kaydedin:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Bu rota, app.use(express.json()) çağrısından ÖNCE kaydedilmek zorunda.
// body-parser isteği parse edilmiş olarak işaretler, sonradan gelen raw() sessizce {} döner.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body; // bir Buffer, nesne değil
  const digest = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw) // Buffer'ı doğrudan hash'le, toString() yok
    .digest('hex');
  console.log('bytes:', raw.length, 'digest:', digest);
  res.sendStatus(200);
});

app.use(express.json()); // diğer bütün rotalar parse edilmiş JSON almaya devam eder
app.listen(3000);

Middleware sırasını değiştiremiyorsanız, bunun yerine parse sırasında bir kopya saklayın:

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));

FastAPI’de Starlette gövdeyi önbelleğe alır; bu yüzden await request.body(), parse edilmiş bir model de alan bir handler’ın içinde bile özgün baytları döndürür:

import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/github")
async def github(request: Request):
    raw = await request.body()  # baytlar, tam olarak geldiği gibi
    expected = "sha256=" + hmac.new(
        os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
    ).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="bad signature")
    return {"ok": True}

Rails’te request.raw_post parse edilmemiş gövdeyi karakter dizisi olarak verir:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def shopify
    raw = request.raw_post
    digest = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
    )
    unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
      return head :unauthorized
    end
    head :ok
  end
end

Go’da gövdeyi kendiniz okursunuz; sonrasında akışın boşaldığını hatırlamanız gerekir:

func handler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
	mac.Write(raw)
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(
		strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
	if err != nil || !hmac.Equal(expected, got) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// raw üzerinden Unmarshal edin, asla r.Body'den — içinde bayt kalmadı.
	w.WriteHeader(http.StatusOK)
}

Spring’de byte[] istemek Jackson’ı tamamen atlar:

@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
                                   @RequestHeader("X-Hub-Signature-256") String header)
        throws GeneralSecurityException {
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
  boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                     header.getBytes(StandardCharsets.UTF_8));
  return ok ? ResponseEntity.ok().build()
            : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

Kontrolü bir filtre yapmak zorundaysa ve controller imzasını değiştiremiyorsanız alternatif ContentCachingRequestWrapper. Onun da kendine ait bir tuzağı var: getContentAsByteArray() yalnızca aşağı akışta bir şey akışı okuduktan sonra bayt döndürür; yani chain.doFilter(...) çağrısından önce çağırırsanız elinize boş bir dizi gelir.

4. Kodlama uyuşmazlıkları: hex, Base64 ve anahtarın kendisi

Digest’inizle header değeri arasında üç ayrı kodlama kararı duruyor ve her biri tek başına karşılaştırmayı bozabilir.

Digest kodlaması. HMAC-SHA256 çıktısı 32 bayttır. Küçük harf hex olarak yazıldığında 64 karakter; standart Base64 olarak yazıldığında = dolgusu dahil 44 karakter. Bölüm 2’deki iki satır bunu doğrudan gösteriyor:

KodlamaKarakterAynı 32 baytın yazımı
hex6409dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
base6444Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=

Tanımadığınız bir header’a bakarken işe yarayan hızlı bir kural: değer 0-9a-f aralığından 64 karakterse hex’tir. = ile biten 44 karakterse ya da içinde +, / veya büyük harf varsa Base64’tür. Tahmin etmek yerine doğrulamak istediğinizde Base64 değerini Base64 çözücü aracından geçirin ve 32 bayt verdiğini kontrol edin; veriyorsa iki karakter dizisi de aynı digest’i tarif ediyor demektir ve siz imzaları değil metin biçimlerini karşılaştırıyordunuz.

Değer öneki. GitHub hex’in önüne sha256= koyar. Slack v0= gönderir. Stripe her şeyi virgülle ayrılmış key=value çiftleri listesine sarar. Bu karakterlerin hiçbiri digest’in parçası değil; o yüzden ya öneki header’dan sıyırın ya da kendi değerinize ekleyin. İkisini de yapmamak, doğru yazılmış bir uygulamanın hmac signature mismatch bildirmesinin en yaygın nedeni. Node’da ise Bölüm 7’nin anlattığı gibi uyuşmazlık bile bildirilmez.

Anahtar kodlaması. Gizli anahtar da bayttır ve aynı karakter dizisinin UTF-8, hex ya da Base64 olarak okunması üç farklı anahtar verir. Size whsec_... gibi metin bir token veren sağlayıcılar UTF-8 bekler, ama pek çok dahili sistem imzalamadan önce çözülmesi gereken Base64 ya da hex gizli anahtarlar dağıtır. Bu arıza biçimi, sorunun JWT sürümüyle aynı. JWT invalid signature hatası: tüm nedenleri ve çözümleri yazısı, verilen bir gizli anahtarın Base64 mü düz metin mi olduğunu nasıl anlayacağınız dahil, konuyu ayrıntılı ele alıyor.

5. Zaman damgası, tolerans ve replay pencereleri

Kusursuz eşleşen bir digest hesaplayıp yine de reddedilebilirsiniz. Zaman damgası gönderen sağlayıcılar onu kontrol etmenizi bekler ve bayatlamış bir zaman damgası, geçerli olmasına rağmen reddetmeniz gereken bir imzadır.

SağlayıcıZaman damgasının yeriPencere
StripeStripe-Signature içinde t=5 dakika (300 saniye)
SlackX-Slack-Request-Timestamp header’ı5 dakika
GitHubgönderilmezuygulanmaz
Shopifygönderilmezuygulanmaz

Pencereyi yanlış ayarlamanın iki yönü de zarar veriyor. Fazla cömert olursa, yakalanmış bir istek siz izin verdiğiniz sürece yeniden oynatılabilir kalır ve zaman damgasını kontrol etmenin anlamı büyük ölçüde kaybolur. Fazla sıkı olursa, sıradan saat kayması gerçek teslimatları reddetmeye başlar. İki sağlayıcının da seçtiği değer beş dakika; onu kopyalamak sağlam bir varsayılan.

Bir toleransı genişletmeden önce saati kontrol edin. Container imajları NTP çalıştırmaz ve bir anlık görüntüden devam ettirilen bir VM, loglarda buna dair tek satır olmadan duvar saatinin dakikalar gerisinde kalabilir. İstikrarlı biçimde kayan bir sunucu, başta ara sıra görülüp sonra tamamına yayılan arızalar üretir; bu, koddaki bir regresyon gibi görünür ama değildir.

Diğer saat hatası birim uyuşmazlığıdır. Tablodaki her sağlayıcı epoch saniyesi gönderir. Bunu JavaScript’in Date.now() gibi bir milisaniye değeriyle karşılaştırırsanız fark, gerçek yaşın kabaca bin katı olur; yani her olay her makul pencerenin dışında kalır. Belirti şudur: digest’in kendisi eşleşirken tolerans kontrolü teslimatların yüzde yüzünü reddeder. Elinizdeki birimin hangisi olduğundan emin değilseniz uzunluk size söyler; epoch saniye ile milisaniye yazısı dönüşümleri ve etraflarındaki zaman dilimi tuzaklarını ele alıyor.

İmzalanan dizeyi kurarken header’daki ham zaman damgası karakter dizisini kullanın, parse edilip yeniden biçimlendirilmiş bir sayıyı değil. 1700000000 değerini float’a çevirip geri yazdırmak 1700000000.0 verebilir ve bu farklı bir bayt dizisidir.

6. Yanlış gizli anahtar ve rotasyona giren anahtarlar

Kodlamalarda daha ileri gitmeden önce en sade nedeni eleyin: gizli anahtar doğru anahtar olmayabilir. Stripe’ın dokümantasyonu açıkça “Stripe her endpoint için ayrı bir gizli anahtar üretir” diyor; aynı URL’yi hem test hem canlı anahtara bağlarsanız “gizli anahtar her biri için farklıdır” diye de ekliyor. Aynı hatanın üç sürümü buradan çıkıyor.

Test modu ile canlı mod ayrı gizli anahtarlar tutar; panel test modundayken kopyalanan bir değer, canlı teslimatların hepsinde patlar. Her endpoint de kendi anahtarını tutar ve dokümantasyon “birden fazla endpoint kullanıyorsanız, imza doğrulamak istediğiniz her biri için ayrı bir gizli anahtar almanız gerekir” diye ekliyor: iki endpoint’i, ortamda tek gizli anahtar bulunan tek bir handler’a yönlendirin, trafiğinizin yarısı patlar. Bir de stripe listen, CLI’nin yerel yönlendirmesi için bir imzalama anahtarı yazdırır; bu, panelde kayıtlı olan her şeyden ayrı bir endpoint olduğu için ikisi birbirinin yerine kullanılamaz.

Bunların hiçbiri dışarıdan bir kodlama hatasına benzemez. Digest düzgün biçimlendirilmiş, karşılaştırma doğru ve ortamınızdaki değer gerçek bir Stripe gizli anahtarı; yalnızca bu teslimatı imzalayan anahtar değil.

Rotasyon, aynı boyutun ayağınızın altından kayması. Bir kodlama sorununa en az benzeyen ve en sık kod hatası olarak yanlış teşhis edilen arıza da bu. Kodunuzda hiçbir şey değişmedi, doğrulama dün çalışıyordu, şimdi olayların bir kısmı patlıyor.

Örtüşme penceresi bilinçli bir tercih. Stripe, rotasyondan sonra eski endpoint gizli anahtarını 24 saate kadar geçerli tutar ve o süre boyunca Stripe-Signature header’ı etkin her gizli anahtar için birer v1 imzası taşır. Shopify tersini yapar: rotasyondan sonra digest’leri yeni gizli anahtarla hesaplamaya başlaması bir saati bulabilir, yani o arada ihtiyacınız olan eski anahtardır.

Kodu bozan Stripe davranışı, çünkü header sanki içinde tek bir imza varmış gibi görünür. , üzerinden bölüp bulduğunuz ilk v1’i almak, ikinci imza ortaya çıkana kadar sorunsuz çalışır; o noktadan sonra hangi gizli anahtarın hangi olayı imzaladığına bağlı olarak kabaca yarı yarıya eşleşirsiniz. Hepsinin üzerinden geçin:

const crypto = require('crypto');

function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const v1 = [];
  for (const pair of header.split(',')) {
    const idx = pair.indexOf('=');
    const key = pair.slice(0, idx);
    const value = pair.slice(idx + 1);
    if (key === 'v1') v1.push(value);
    else if (key === 't') t = value; // özgün karakter dizisini koru
  }
  if (t === null || v1.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > toleranceSec) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();

  return v1.some((sig) => {
    const received = Buffer.from(sig, 'hex');
    return received.length === expected.length &&
      crypto.timingSafeEqual(received, expected);
  });
}

Döngünün ötesinde iki ayrıntı önemli. Zaman damgası, imzalanan payload’a geldiği karakter dizisi hâliyle girer ve gövde, şablon interpolasyonu yerine bayt olarak birleştirilir, çünkü interpolasyon onu önce UTF-8 olarak çözerdi.

Rotasyonu kendi tarafınızda yaptığınızda aynı kalıp geçerli: örtüşme süresi boyunca hem eski hem yeni gizli anahtarı kabul edin, sonra eskisini bırakın. Geçtiğiniz yeni anahtarın tam entropiye ihtiyacı var; o yüzden elle yazmak yerine üretin: 256 bitlik rastgele bir değer için imzalama anahtarı üretici gibi bir araç kullanın.

7. İmzaları zamanlama sızdırmadan karşılaştırmak

İki digest elinize geçtikten sonra onları nasıl karşılaştırdığınız bir güvenlik kararıdır. Karakter dizisi eşitliği, farklı bir bayt bulur bulmaz geri döner; yani geçen süre baştan kaç baytın doğru olduğunu açık eder. Çok sayıda istek gönderebilen bir saldırgan bunu kullanarak geçerli imzayı bayt bayt geri kazanır. Bu saldırı internet üzerinden yavaş ve gürültülüdür, yerel bir ağda ise tümüyle uygulanabilir.

Her çalışma zamanı sabit zamanlı bir karşılaştırma getirir:

DilSabit zamanlı karşılaştırmaUzunluklar farklı olduğunda
Nodecrypto.timingSafeEqual(a, b)istisna fırlatır
Pythonhmac.compare_digest(a, b)False döner
Gohmac.Equal(a, b)false döner
PHPhash_equals($known, $user)false döner
RubyOpenSSL.secure_compare(a, b)false döner

Kafa karıştıran bir olay sınıfının tamamı bu son kolondan geliyor. Node buradaki aykırı örnek ve sessizce başarısız olmuyor:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

Hex bir SHA-256 digest’i 64 karakterdir. X-Hub-Signature-256 içindeki değer 71 karakter, çünkü sha256= yedi karakter. Öneki sıyırmayı unutun, iki buffer’ın uzunlukları farklı olur ve timingSafeEqual false döndürmek yerine istisna fırlatır. Yakalanmazsa bu istisna handler’ınızın dışına çıkar ve Express onu 500’e çevirir.

webhook 401 unauthorized yanıtı ararken bir sunucu hatası alıyorsunuz; bunun üzerine handler’ınızı, veritabanı çağrınızı, olay dağıtıcınızı okumaya gidiyorsunuz. Gerçek hata ise karşılaştırmanın bir satır üstünde. 64 karakterlik hex bir digest’i 44 karakterlik Base64 biriyle karşılaştırmak da aynı nedenle istisna fırlatır; yani Node’da bir kodlama uyuşmazlığı da temiz bir reddetme yerine 500 olarak yüzeye çıkar.

Çözüm, uzunluğu kendiniz kontrol edip false döndürmek:

function safeEqualHex(receivedHex, expectedHex) {
  const a = Buffer.from(receivedHex, 'hex');
  const b = Buffer.from(expectedHex, 'hex');
  if (a.length !== b.length) return false; // çağrıdan önce kontrol et
  return crypto.timingSafeEqual(a, b);
}

Uzunluğu sızdırmak zararsız; bir digest’in uzunluğu algoritma tarafından sabitlenmiştir ve herkese açıktır. Sızdırmamanız gereken şey, hangi önekin eşleştiği. HMAC üretici aracının Verify sekmesi, uzunluk farkını erken dönmek yerine aynı sabit zamanlı toplamın içine katar; böylece uzunluk uyuşmazlığı bir istisna değil sade bir false olarak geri döner ve tek kullanımlık kod yazmadan bir header değerini hesapladığınız digest’e karşı kontrol edebilirsiniz.

8. Taşıma katmanı baytlarınızı değiştirdiğinde

İmzalanan dizeyi, ham gövdeyi, kodlamaları, saati ve rotasyonu elediniz. Geriye kalan olasılık, sürecinize ulaşan baytların sağlayıcıdan çıkan baytlar olmaması.

Sıkıştırma. Bir sağlayıcı ya da proxy gövdeyi Content-Encoding: gzip ile sıkıştırarak gönderebilir. İmza sıkıştırılmamış payload’u kapsar, yani hash’i açtıktan sonra almanız gerekir. Bazı framework’ler bunu kendiliğinden açar, bazıları sıkıştırılmış baytları size verir; logunuzda ikili çöp gibi görünen bir gövde bunu ele veren işarettir.

Parçalı aktarım. Transfer-Encoding: chunked durumunda Content-Length yoktur ve okuma buffer’ını boyutlandırmak için o header’a güvenen kod gövdeyi keser. Kesilmiş bir gövdenin digest’i geçerli bir saçmalıktır: asla eşleşmez ve hiçbir şey yanlış görünmez.

Proxy’ler ve WAF’lar. Gövdeyi okuyup yeniden yazan her katman onu değiştirebilir. AWS API Gateway, gövde bir Lambda’ya ulaşmadan önce onu Base64 ile kodlayabilir; bu durumda hash almadan önce çözmeniz gerekir. Uygulama yük dengeleyiciler, service mesh’ler ve web uygulama güvenlik duvarları da payload’ları normalleştirebilir ya da yeniden kodlayabilir. Test etmek için handler’ınızın gördüğü bayt uzunluğunu, sağlayıcının gönderdiği Content-Length ile karşılaştırın.

Karakter kodlaması ve BOM. Payload’lar ASCII dışı karakterler içerebilir ve GitHub’ın dokümantasyonu payload’un UTF-8 olarak ele alınması gerektiğini açıkça yazar. Gövdeyi yanlış karakter kümesiyle karakter dizisine çözüp yeniden kodlamak, çok baytlı her karakteri yok eder. İyi niyetli bir editörün ya da serileştiricinin öne eklediği UTF-8 bayt sırası işareti (EF BB BF), hiç imzalanmamış üç bayt ekler.

Satır sonları ve fazladan boşluklar. Metin kipinde bir dosya sınırından geçmiş bir gövde, LF yerine CRLF ile gelebilir. Sağlayıcının imzalama dizesine dair spesifikasyonunu da okuyun: bazıları sona kendi karakterini ekler ve Typeform, sondaki satır sonunun hash’lenen şeyin parçası olduğu belgelenmiş bir örnek. Bir sağlayıcının dokümanı fazladan herhangi bir karakterden söz ediyorsa bunu harfi harfine uygulayın.

9. Tekrarlanabilir bir hata ayıklama akışı

Bunları sırayla uygulayın. Her adım ya hatayı bulur ya bir dalı eler; amaç erken durmak.

  1. Hiçbir middleware çalışmadan önce ham baytları loglayın. İstek yaşam döngüsünde ulaşabildiğiniz en erken noktadan gövdeyi bir dosyaya yazın ya da bayt uzunluğunu ve SHA-256’sını loglayın. Yalnızca uzunluk bile şaşırtıcı sayıda vakayı çözer: beklenenden bir fazla olan değer sondaki satır sonudur, üç fazla olan BOM’dur.
  2. Digest’i elle hesaplayın. Tam olarak o baytları ve gizli anahtarınızı HMAC üretici aracına yapıştırın, SHA-256’yı seçin ve çıktı biçimini header’la eşleşecek şekilde ayarlayın. En çok işi yapan adım bu, çünkü problemi tertemiz ikiye bölüyor.
  3. Elle hesapladığınız değeri header’la karşılaştırın. Eşitse baytlar ve gizli anahtar doğru demektir, hata kod yolunuzun bir yerindedir; gidip karşılaştırmanızı okuyun. Eşit değilse girdilerden biri yanlış, devam edin.
  4. İmzalanan dizeyi Bölüm 2’deki tabloyla karşılaştırın. Bu sağlayıcı öne bir zaman damgası ekliyor mu? Hangi ayırıcıyla? Öneki araçta ekleyip yeniden hesaplayın.
  5. Digest kodlamasını değiştirin. Hem hex hem Base64 olarak yeniden hesaplayıp ikisini de header’la karşılaştırın. Sonunda = bulunan 44 karakterlik bir header değeri, kodunuz ne varsayıyor olursa olsun Base64’tür.
  6. Anahtar kodlamasını değiştirin. Gizli anahtarı önce metin, sonra hex, sonra Base64 olarak deneyin. Genellikle üçünden biri eşleşme üretir ve bu size sağlayıcının ne beklediğini söyler.
  7. Saati ve rotasyon durumunu kontrol edin. Sunucunuzun saatini bilinen bir kaynakla karşılaştırın, epoch saniyesi işlediğinizi doğrulayın ve sağlayıcının panosunda son 24 saatte bir rotasyon olup olmadığına bakın.

İki alışkanlık bu döngüyü çok hızlandırıyor. Birincisi, bir sonraki teslimatı beklemek yerine patlayan tek bir payload’u yakalayıp onun üzerinden çevrimdışı çalışın. İkincisi, yakaladığınız gövdeyi sabit bir imzayla endpoint’inize karşı yeniden gönderin; böylece girdi denemeler arasında hiç değişmez. cURL komut oluşturucu isteği tam olarak istediğiniz header’larla ve bir dosyadan okunan bir gövdeyle kurar, bu da baytları çalıştırmalar arasında sabit tutar. Arızayı istediğiniz an yeniden üretebilmek, arada bir çıkan bir webhook signature verification failed bildirimini beş dakikalık bir düzeltmeye çeviren şey.

Yine de destek talebi açmanız gerekiyorsa şunları ekleyin: hash’lediğiniz gövdenin bayt uzunluğu, header değerinin birebir kendisi, kurduğunuz imzalama dizesi ve digest kodlaması. Gizli anahtarın kendisini asla eklemeyin.

SSS

Webhook imzam yerelde çalışıyor, neden üretimde patlıyor?

Test payload’unuz büyük olasılıkla bir JSON round-trip’inden değişmeden çıkıyor, dolayısıyla onu yeniden serileştirmek zararsız. Gerçek payload’lar float, büyük tam sayı, Unicode kaçış dizisi ya da fazladan boşluk içerir ve bunlar baytları gerçekten değiştirir. Yeniden serileştirilmiş bir kopya yerine ham gövdeyi imzalayın; hangi biçimlerin bozulduğunu Bölüm 3’teki tablo gösteriyor.

İmzaları karşılaştırırken sha256= önekini dahil etmeli miyim?

Ya sıyırın ya da iki karakter dizisi birebir eşleşsin diye kendi değerinize ekleyin. Hesapladığınız hex digest 64 karakter, header değeri ise önekle birlikte 71 karakter. Bazı karşılaştırma fonksiyonları uzunluk uyuşmazlığında false döner, Node’un timingSafeEqual fonksiyonu ise false döndürmek yerine istisna fırlatır.

Framework JSON’u parse ettikten sonra imzayı doğrulayabilir miyim?

Güvenilir biçimde hayır. Yeniden serileştirme özgün baytları yalnızca float içermeyen, 2^53’ün üzerinde tam sayı içermeyen, Unicode kaçış dizisi ve fazladan boşluk barındırmayan payload’lar için yeniden üretir. Bunlardan biri ortaya çıktığı an digest değişir; doğrulama testlerde geçer, üretimdeki olayların bir kısmında patlar.

Stripe ile GitHub aynı payload için neden farklı imzalar üretiyor?

Çünkü farklı dizeleri hash’liyorlar. GitHub yalnızca ham gövdeyi imzalar. Stripe zaman damgasını, birebir bir . karakterini, sonra gövdeyi imzalar; yani iki farklı zamanda teslim edilen tek bir payload iki farklı digest verir. Slack öne v0: ile kendi zaman damgasını ekler. Aynı algoritma, farklı girdi.

Zaman damgası toleransı ne kadar olmalı?

Stripe ve Slack beş dakika kullanıyor, onu kopyalamak makul bir varsayılan. Daha kısa pencereler, sunucu saatiniz kayar kaymaz meşru teslimatları reddeder. Daha uzun pencereler, yakalanmış bir isteğin yeniden oynatılabileceği süreyi genişletir. Toleransı gevşetmeden önce saatleri NTP ile eşitleyin.

Uzunluklar farklı olduğunda timingSafeEqual false döner mi?

Hayır. Node RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length istisnasını fırlatır. Yakalanmazsa bu 401 yerine 500 olur ve sizi karşılaştırmanın üstündeki satır yerine handler’ınızda hata ayıklamaya yönlendirir. Önce uzunlukları karşılaştırın ve false’u kendiniz döndürün.

Sağlayıcım gizli anahtarı değiştirdi, bazı webhook’lar neden hâlâ patlıyor?

Rotasyon pencereleri örtüşür. Stripe eski gizli anahtarı 24 saate kadar geçerli tutar ve etkin her gizli anahtar için birer v1 imzası gönderir; dolayısıyla yalnızca ilk v1’i okuyan kod olayların kabaca yarısında patlar. Shopify’ın yeni gizli anahtarı kullanmaya başlaması bir saati bulabilir.

Sonuç

Doğrulama bir bayt karşılaştırmasıdır; bu yüzden webhook signature verification failed her zaman baytlar üzerindeki bir anlaşmazlığa iner, kriptografik bir şeye değil. Hata ayıklarken şu boyutları ayrı tutun:

  • Hangi baytlar imzalandı. Ham gövdeyi, hiçbir parser ona dokunmadan önce yakalayın. Yeniden serileştirilmiş bir nesneyi asla hash’lemeyin; testlerinizi geçecek kadar sık eşleşir, sonra üretimdeki olayların bir kısmında patlar.
  • Hangi anahtar baytları kullanıldı. Tek bir gizli anahtarın metin, hex ve Base64 okumaları üç farklı anahtar verir.
  • Karşılaştırmayı hangi kodlamada yaptınız. Hex 64 karakter, Base64 44 karakter ve ikisi de aynı 32 baytı tarif ediyor.
  • Geri kalan her şey. Zaman damgası öneki, değer öneki, tolerans penceresi, rotasyon örtüşmesi ve taşıma katmanı; kabaca bu olasılık sırasıyla.
  • Nasıl karşılaştırdınız. Uzunluğu kontrolden geçirin, sonra çalışma zamanınızın sabit zamanlı fonksiyonunu kullanın.

Karşılaştırmak için güvenebileceğiniz bir değer istediğinizde onu uygulamanızın dışında hesaplayın: gövdeyi ve gizli anahtarı HMAC üretici aracına yapıştırın; hangi tarafın yanlış olduğu böylece ortaya çıkar.

Etiketler: webhook hmac api-security debugging authentication