Cloudinary signs the webhook events it sends to your endpoint, allowing you to validate that they were not sent by a third-party.
Creating a webhook endpoint on your server is no different from creating any page on your website. With PHP for example, you might create a new .php file on your server.
Each Cloudinary webhook includes X-Cld-Signature header which contains the request's signature string and X-Cld-Timestamp header which contains the request timestamp.
If you are working with multiple API/Secret pairs, please take a look at our documentation for Dedicated API key for webhook notifications which explains how to specify an API key that will serve as the dedicated key for verifying webhook notifications.
Here's an example POST request sent by Cloudinary:
POST /my_notification_endpoint HTTP/1.1 X-Cld-Timestamp: 1368881627 X-Cld-Signature: 29a383e289bc569310a8ca9899019a3167b4909e Content-Type: application/json
Checking signatures:
Step 1: Prepare the signed_payload
string
This is achieved by concatenating:
- The request body
- The timestamp (X-Cld-Timestamp)
$signed_payload = $upload_response . $headers["X-Cld-Timestamp"];
Step 2: Determine the expected signature
- Compute a HMAC with the SHA1 hash function, using the API Secret as the key and the
signed_payload
string as the message.
Note:By default, SHA-1 digest is used to create and verify all Cloudinary signatures. To instead use the SHA-256 digest for all your account signatures, please submit a request.
sha1($signed_payload . $api_secret)
Step 3: Compare signatures
- Compare the signature in the header (X-Cld-Signature) to the expected signature. If a signatures match, it means that the request is validated :)
if (sha1($signed_payload . $api_secret) === $headers['X-Cld-Signature']) {
//signatures match
}
else {
//signatures NOT match
}
Step 4: Preventing replay attacks
A replay attack is when an attacker intercepts a valid payload and its signature and re-transmits them. To mitigate such attacks, Cloudinary includes a timestamp in the X-Cld-Timestamp header. As this timestamp is part of the signed payload, it is also verified by the signature, so an attacker cannot change the timestamp without invalidating the signature. You can decide to reject the payload if the signature is valid but the timestamp is too old.
- To prevent against timing attacks, compare the request timestamp (X-Cld-Timestamp) to current timestamp.
if ($headers["X-Cld-Timestamp"] <= strtotime('-2 hours')) { //Signatures match, but older than 2 hours } else { //Signatures match, and timestamp }
Full PHP code example:
<?php
//Read request body
$body = file_get_contents('php://input');
$api_secret = 'replace_with_real_api_secret';
//Signature validation
if (validateSignature(getallheaders(), $body, $api_secret)) {
//Use the response for your needs
$resposne = json_decode($body);
$public_id = $resposne->public_id;
} else {
die("Validation failed");
}
function validateSignature($headers, $upload_response, $api_secret)
{
$signed_payload = $upload_response . $headers["X-Cld-Timestamp"];
//Compute a HMAC with the SHA1 hash function, using the api secret as the key and the signed_payload string as the message
if (sha1($signed_payload . $api_secret) === $headers['X-Cld-Signature']) {
//To prevent against timing attacks, we compare the expected signature to each of the received signatures.
if ($headers["X-Cld-Timestamp"] <= strtotime('-2 hours')) {
//Signatures match, but older than 2 hours
return false;
} else {
//Signatures match, and timestamp
return true;
}
} else {
//Signatures NOT match
return false;
}
}
Comments
10 comments
in javascript, is the response body the JSON.stringify string of the body?!
I am following these steps and getting a different signature than what's in the `x-cld-signature` header.
Hi Rayee,
The
req.body
should be a string (of JSON), so it should look like this:So your signed payload should look like this:
Let me know if that doesn't work.
It does not work. the `req.body` is an Object - so it needs to be stringified, which is what you are also doing on your unit tests:
https://github.com/cloudinary/cloudinary_npm/blob/2e5b3f4c6f9d0c0e79f174e211abc61964e8e941/test/utils/utils_spec.js#L1464
Hi Rayee,
I assumed you are using express, and if you have something like this on your app, then yes, the body will be parsed as JSON object instead of JSON string:
If that's the case, then you are correct, it needs to be stringify using:
However, I do noticed there might be a bug in verifyNotificationSignature:
Since Date.now() returns in milliseconds, the above condition would always be true, which resulted in return false for verifyNotificationSignature method as you observed.
I will reach out to our SDK team on this finding for the fix.
In the meantime, as a workaround, please use either one of the following:
You can check the following code that I use to analyze and verify my analysis above: https://replit.com/@leptians/verifyNotificationSignature#index.js
Thanks for your response.
I actually already saw that bug with the Date and submitted a PR here: https://github.com/cloudinary/cloudinary_npm/pull/515
Besides that issue, it seems the signature itself that I get on
is different than what I get from webhook_signature call (which is actually identical to the way I create it using crypto)
Not sure it makes a difference but I use a preset with notificaiton_url and not setting the notification_url directly on the upload call
Hi Rayee,
Thanks for the PR, our SDK team is reviewing it.
Regarding the signature in the header does not match with webhook_signature, that's interesting, because on my testing (with the code I shared earlier), the signatures are all identical in these 3 aspects:
The only thing that could be different is that your account and your app are using different signature algorithm. Reviewing the account associated with your profile, your cloud is using SHA1. Can you confirm if you are also using SHA1 on your app?
Using the notification_url in the preset should not make a different, but can you share your upload preset name so I can duplicate on my environment and double-check on this?
Hi Rayee,
I just tested signature of an upload with upload preset, I can confirm the signature in the headers matches with using webhook_signature as well as manual SHA1 crypto.
Can you share your code so I can take a look at it?
Looks like the current issue is indeed due to signing the upload with sha256 with an account setup of sha1- causing the signature in the header to be sha1.
I am considering whether I should just use sha1 when creating the signature or change account settings. Will let you know on a private message
Hi Rayee,
Sounds good. Let's continue our discussion in the private ticket since seems like this is very specific to how your account is setup.
Just quick update regarding verifyNotificationSignature, the issue has been fixed in v1.27.0. Thanks Rayee for the PR
Please sign in to leave a comment.