Webhooks are an ideal way to send information automatically to an external application. However, it is critical to ensure that the receiving app or server validates the source before accepting requests. To avoid potential security threats, you can secure your webhooks.
Contentstack offers security measures that you can implement when setting up a webhook. These include Basic Auth, OAuth 2.0 Client Credential, Bearer Token, Custom Headers, Default (Contentstack Certificate) Signature, HMAC Signature, Time Stamped Messages, and IP Whitelisting.
Contentstack signs every webhook payload so the receiving application can verify it came from Contentstack. Two signing methods are available, and you select one per webhook with the Request Signing Method field:
Let’s look at the ways you can secure your webhook event data in detail.
When setting up a webhook, basic authentication, i.e., Basic Auth, allows you to set a username and password associated with your HTTP endpoint. With this method, your basic auth field values are included in the header of the HTTP request.
To configure this method, log in to your Contentstack account, go to your stack, and perform the following steps:
Your URL is now secured with the basic auth username and password.
OAuth 2.0 provides a more secure and robust authentication method by allowing you to authenticate requests using access tokens.
Note: The Basic Auth method is available by default. To enable the OAuth 2.0 authentication method for your organization, contact our support team.
To configure this method, log in to your Contentstack account, go to your stack, and perform the following steps:
Note: To get the values for the above fields, refer to your OAuth application settings. The request query parameters are appended to the access token URL.
Bearer token is an authentication method that allows you to securely pass a token in the HTTP header of your webhook requests. The server then verifies the token to authenticate the request.
Note: The Basic Auth method is available by default. To enable the Bearer Token authentication method for your organization, contact our support team.
To configure this method, log in to your Contentstack account, go to your stack, and perform the following steps:
Custom headers provide an additional validation signal but should not replace proper authentication or signature verification.
Custom headers are key-value parameters that you send and receive in the header of each call to your notifying URL.
To set this method, log in to your Contentstack account, go to your stack, and perform the following steps:
Note: You can set multiple custom header key-value pairs.
The Default (Contentstack Certificate) signature is the default signing method, shown as Default (CS Cert) in the Request Signing Method field on a webhook. Contentstack signs all webhook events sent to your endpoints with this signature, which appears in each event's X-Contentstack-Request-Signature header. It allows you to verify the integrity of the data and the authenticity of the provider (Contentstack) the data comes from.
Whenever a webhook is triggered for a specific event, Contentstack generates a Default (Contentstack Certificate) signature based on the payload and appends it to the X-Contentstack-Request-Signature header of the HTTP request.
Note: Contentstack uses the SHA-256 algorithm and an RSA-based private key to generate webhook signatures.
Each signature is denoted by a unique identifier and prefixed with v1=. The following example shows the possible values for this response header.
X-Contentstack-Request-Signature:
v1=gk2f/Hzbm7TcNPs8g/AoKaGsK1yXaa5/EnEpNEzyQ67RElj09SNote: Each webhook signature contains 256 characters.
Perform the following steps to check whether the webhook data comes from an authenticated source.
To verify a webhook signature, you need the Contentstack Signing Public Key shared in the response. To obtain the public key, hit the API endpoint below.
https://[DOMAIN]/.well-known/public-keys.jsonNote: Here, DOMAIN refers to the host in the region-specific login endpoint that you are currently using to access the Contentstack app.
The API endpoint returns the signing public key in the response body:
// RESPONSE
const response = {
"signing-key": "-----BEGIN RSA PUBLIC KEY-----\212313131\n-----END RSA PUBLIC KEY-----"
;
const publicKey = response["signing-key"];
}Note: You can also store the content of the public key in a file for access whenever needed.
To extract the webhook signature from the response header, use the , character as a separator and split the header. This returns a list of elements. Then use the = character as a separator to split each element and retrieve a prefix and value pair.
const signatureString = req.get('X-Contentstack-Request-Signature');
const signature = signatureString.split(",")[0].split("=")[1];
const body = req.body;You can use the crypto.verify() method to verify the webhook signature attached to a specific webhook event. Pass the request body, signature, and public key to this method, as shown below.
const hashAlgo = 'sha256';
const isVerified = crypto.verify(
hashAlgo,
Buffer.from(JSON.stringify(body)),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
},
Buffer.from(signature, 'base64')
);The crypto.verify() method returns a boolean value. It returns true if verification is successful and false if it fails. If it returns false, reject the request.
Here is a sample codebase of what your verification script (Node.js) should look like:
const express = require('express');
const crypto = require('crypto');
const fs = require('fs');
const HASH_ALGO = 'sha256';
const PORT = 3000;
const PUBLIC_KEY = importPublicKey();
const app = express();
app.use(express.json());
app.post('/webhook', (req, res) => {
const signature = req.get('X-Contentstack-Request-Signature');
const body = req.body;
const isVerified = crypto.verify(
HASH_ALGO,
Buffer.from(JSON.stringify(body)),
{
key: PUBLIC_KEY,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
},
Buffer.from(signature.split(',')[0].split('=')[1], 'base64')
);
if (isVerified) {
console.log('verified!', body)
res.json();
} else {
console.log('failed!')
res.send('Unable to verify signature');
}
});
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
function importPublicKey() {
const publicKeyFile = fs.readFileSync('public.key', 'utf8');
return crypto.createPublicKey({
key: publicKeyFile,
format: 'pem',
type: 'pkcs1'
});
}Here is a sample codebase of what your verification script (Java) should look like:
import java.io.IOException;
import java.security.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public class CoreVerifyPublic {
private static final String SIGNATURE_ALGO = "SHA256withRSA";
public static void main(String[] args) {
try {
PublicKey publicKey = getPublicKeyFromPem("public_key.pem");
String signature = "X-Contentstack-Request-Signature";
//Replace X-Contentstack-Request-Signature with your public key.
String signatureBase64 = signature.split(",")[0].split("=")[1];
// Convert the signature from Base64 string to byte array
byte[] receivedSignatureBytes = Base64.getDecoder().decode(signatureBase64);
boolean isVerified = verifySignature(publicKey, receivedSignatureBytes, getJsonBody());
if (isVerified) {
System.out.println("Verified!");
} else {
System.out.println("Failed!");
}
} catch (IOException | NoSuchAlgorithmException | InvalidKeyException | SignatureException | InvalidKeySpecException e) {
e.printStackTrace();
}
}
private static PublicKey getPublicKeyFromPem(String filePath) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
Path path = Paths.get(filePath);
System.out.println(filePath);
byte[] publicKeyBytes = Files.readAllBytes(path);
String publicKeyPem = new String(publicKeyBytes, StandardCharsets.UTF_8);
publicKeyPem = publicKeyPem.replace("-----BEGIN RSA PUBLIC KEY-----", "");
publicKeyPem = publicKeyPem.replace("-----END RSA PUBLIC KEY-----", "");
publicKeyPem = publicKeyPem.replaceAll("\\s+", "");
System.out.println(publicKeyPem.length());
byte[] publicKeyDecoded = Base64.getMimeDecoder().decode(publicKeyPem);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyDecoded);
return keyFactory.generatePublic(keySpec);
}
private static boolean verifySignature(PublicKey publicKey, byte[] signatureBytes, byte[] messageBytes)
throws NoSuchAlgorithmException, InvalidKeyException, SignatureException {
Signature signature = Signature.getInstance(SIGNATURE_ALGO);
signature.initVerify(publicKey);
signature.update(messageBytes);
return signature.verify(signatureBytes);
}
private static byte[] getJsonBody() {
String jsonString = "{ \"module\": \"entry\", \"api_key\": \"bltd83b84cfa0c48b61\", \"event\": \"publish\", \"triggered_at\": \"2023-03-28T19:35:13.578Z\", \"data\": { \"entry\": { \"uid\": \"blte9ebf4643da326b4\", \"title\": \"How do I deposit money into my digital checking account?\", \"locale\": \"en-us\", \"_version\": 5 }, \"content_type\": { \"uid\": \"help_center_article_template\", \"title\": \"Help Center Article\" }, \"environment\": { \"uid\": \"blte8035ec83616b4bc\", \"name\": \"integration\", \"urls\": [{ \"url\": \"\", \"locale\": \"en-us\" }] }, \"action\": \"publish\", \"status\": \"success\", \"locale\": \"en-us\" } }";
return jsonString.getBytes(StandardCharsets.UTF_8);
}
}In addition to the Default (Contentstack Certificate) signature, Contentstack supports HMAC signing. With HMAC signing, Contentstack signs each webhook payload using a secret key that is unique to your organization and the HMAC-SHA256 algorithm, rather than the shared Contentstack certificate. This gives your organization an isolated signing key that you can rotate on your own schedule.
A webhook uses HMAC signing only when both of the following are true:
Additional Resource: To enable, regenerate, or disable the organization's HMAC secret key, refer to HMAC Signing.
When HMAC signing is active, Contentstack adds the signature to the x-contentstack-hmac-signature header. The header contains a timestamp (t) and one or more HMAC-SHA256 signatures (v1):
x-contentstack-hmac-signature: t=1778729300,v1=<signature>During a key rotation grace period, both the new and the deprecated key sign the payload, so the header contains more than one v1 value:
x-contentstack-hmac-signature: t=1778729300,v1=<new_signature>,v1=<old_signature>Your application should treat the request as valid if any one of the v1 signatures matches.
| Parameter | Description |
|---|---|
| t | Unix timestamp used to generate the signature. |
| v1 | HMAC-SHA256 signature. |
| Multiple v1 values | Present during a secret rotation grace period. |
Verify the signature against the exact raw request body you receive. Do not parse and re-stringify the JSON, change whitespace, or reorder fields, because any change to the payload causes verification to fail. The signed payload format is:
${timestamp}.${raw_request_body}The example below verifies an incoming signature in Node.js. Replace secret with your organization's HMAC secret key. The function returns true if any signature in the header matches:
import crypto from "crypto";
function verifyWebhookSignature({ signatureHeader, rawBody, secret }) {
if (!signatureHeader) return false;
// Example header: t=1778729300,v1=abc,v1=def
const parts = signatureHeader.split(",");
let timestamp = "";
const signatures = [];
for (const part of parts) {
const [key, value] = part.trim().split("=");
if (key === "t") timestamp = value;
if (key === "v1") signatures.push(value);
}
if (!timestamp || signatures.length === 0) return false;
// Use the raw request body exactly as received.
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Match against any v1 signature.
return signatures.some((signature) =>
crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expectedSignature, "hex")
)
);
}When you read the raw body in Express, use the raw body parser so the payload is not modified before verification:
import express from "express";
const app = express();
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signatureHeader = req.headers["x-contentstack-hmac-signature"];
const rawBody = req.body.toString("utf8");
const isValid = verifyWebhookSignature({
signatureHeader,
rawBody,
secret: process.env.WEBHOOK_SECRET,
});
if (!isValid) {
return res.status(401).send("Invalid signature");
}
res.sendStatus(200);
}
);A replay attack occurs when an attacker captures a valid webhook request and resends it later to trigger duplicate or unauthorized processing. To help prevent such attacks, Contentstack attaches a timestamp to the request. For the Default (Contentstack Certificate) signature, the timestamp is passed against the triggered_at key in the request body. For HMAC signatures, the timestamp is the t value in the x-contentstack-hmac-signature header.
Compare the received timestamp to your current local timestamp to determine whether it is outside your defined tolerance. If it exceeds the tolerance limit, your application can reject the request.
The following sample defines the signature timestamp tolerance for the Default (Contentstack Certificate) signature:
let receivedTimestamp = req.body['triggered_at'];
let localTimestamp = Date.now();
// in case the defined tolerance is 1 minute, 60*1000 milliseconds
if (localTimestamp - receivedTimestamp > 60000) {
// reject request
}IP whitelisting is another security feature that gives only an approved list of IP addresses permission to access your domains.
To protect your domain from potential attacks, Contentstack provides you with a specific set of IP addresses that you can whitelist. This lets you limit and control access to trusted IPs only and verify whether the data is sent from Contentstack.
To receive the Contentstack IPs, contact our support team today.
Additional Resource: You can also read about how to Pass Contentstack Webhooks through Firewall in our detailed documentation.