Start learning free

LESSON 110 OF 188 · BACKEND: PAYMENTS AND OTHER SERVICES

Check the Webhook's Signature

Practice

Anyone on the internet can send a POST to /webhooks/payments, not only the provider. Right now a forged event saying payment.succeeded for order 9 marks it paid, and 1250 worth of goods would ship for free. The provider proves that an event comes from it by signing it with a secret that only it and your shop know.

How signing works: the provider computes an HMAC of the exact bytes of the body with the secret (here with SHA-256) and sends it in the X-Signature header. Your shop computes the same HMAC and compares the two. Nobody can compute it without the secret, and changing one character of the body changes it completely. With another key and message:

sig = hmac.new(b"key", b"hello", hashlib.sha256).hexdigest()
print(len(sig))                          # 64 hexadecimal characters
print(hmac.compare_digest(sig, "abc"))   # False

Compare with hmac.compare_digest, not ==: == stops at the first different character, so an attacker who measures the time can find the signature one character at a time. When the signatures don't match, answer 400 before the event is read, so a forged event changes nothing:

POST /webhooks/payments  (signed with the secret)   →  200  {"received": true}
POST /webhooks/payments  (a made-up X-Signature)    →  400  {"error": "Bad signature"}
POST /webhooks/payments  (no X-Signature header)    →  400  {"error": "Bad signature"}
GET /orders/9   →  200  {…, "payment_id": null, "status": "pending", "total": 1250}

Your task: store the secret "whsec_test" in WEBHOOK_SECRET. At the top of payment_webhook, compute the expected signature from the raw body, read the X-Signature header, and answer {"error": "Bad signature"} with 400 when the two don't match.


Assignment
Follow the Coding Tutorial to check the signature of every event that reaches POST /webhooks/payments.


Hint
request.get_data() gives the body as bytes, and the key must be bytes too: .encode() turns the secret into bytes. Give request.headers.get a default of "", so a missing header simply doesn't match.


STEP 1 OF 6
    Ctrl + Enter to check