Verify POST request signatures

Learn how to verify the request signatures of POST requests.

When Canva sends a POST request to an app, it includes a comma-separated list of request signatures in the X-Canva-Signatures header.

A request signature is a unique string that identifies the request:

e03c80881a48bb730cee12c7e842301b0b116b970a03068a5f5263358926e897

Before you can submit an app for review, the app must:

  1. Calculate a signature for each request.
  2. Check if the calculated signature is included in the comma-separated list of signatures.
  3. Reject the request with a 401 status code if the calculated signature is not included in the list of signatures.

This protects the app from a variety of attacks.

Every app has a client secret. This is a sensitive value that's shared between Canva and your app. You must use the secret to calculate a request signature.

To get your app's client secret:

  1. Navigate to an app via the Developer Portal.
  2. Click Verification.
  3. Under the Client secret heading, click Copy.

Canva provides the Client secret as a base64-encoded string. Your app must decode this string into a byte array. The following snippet demonstrates how to do this in Node.js:

const secret = process.env.CLIENT_SECRET;
const key = Buffer.from(secret, "base64");
console.log(key);
javascript

To verify that a request signature was generated by Canva, an app must calculate the signature itself and compare it to the provided signatures. This requires two ingredients: a key and a message.

The key is the decoded client secret.

In a POST request, the message is a colon-separated string that contains the following values:

The version of Canva's API that's sending the request. You must set this value to v1.

The UNIX timestamp (in seconds) of when Canva sent the request. This timestamp is provided in the X-Canva-Timestamp header.

The path that Canva appends to the extension's Base URL, such as:

  • /content/resources/find
  • /publish/resources/find
  • /publish/resources/get
  • /publish/resources/upload

If the Base URL includes additional path segments, do not include these when calculating a request signature. For example, if the Base URL is example.com/api, omit /api.

These are examples of invalid paths:

  • /api/content/resources/find
  • /api/publish/resources/upload

The raw, unserialized body of the request. This is the body of the request before it's parsed as JSON.

This snippet demonstrates how to construct a message for a POST request:

const express = require("express");
const app = express();
app.use(
express.json({
verify: (request, response, buffer) => {
request.rawBody = buffer.toString();
},
})
);
app.post("/content/resources/find", async (request, response) => {
const version = "v1";
const timestamp = request.header("X-Canva-Timestamp");
const path = getPathForSignatureVerification(request.path);
const body = request.rawBody;
const message = `${version}:${timestamp}:${path}:${body}`;
console.log(message);
});
const getPathForSignatureVerification = (input) => {
const paths = [
"/configuration",
"/configuration/delete",
"/content/resources/find",
"/publish/resources/find",
"/publish/resources/get",
"/publish/resources/upload",
];
return paths.find((path) => input.endsWith(path));
};
app.listen(process.env.PORT || 3000);
javascript

This is an example of a message for a POST request:

v1:1586167939:/content/resources/find:{"user":"AXqAwpfw2GuMaXL9-zBB8LKhViH6JTO068_8XTXjaJE=","brand":"AXqAwpfm9BvNmaakx13Cz_r13DTeRea9hWZt09b_u7s=","label":"CONTENT","limit":8,"query":"","locale":"en-GB","type":"EMBED"}
bash

When you have a key and a message, use these values to calculate a SHA-256 hash and convert that hash into a hex-encoded string. The result is the signature of the request.

This snippet demonstrates how to calculate a signature in Node.js:

const { createHmac } = require("crypto");
const signature = createHmac("sha256", key).update(message).digest("hex");
console.log(signature);
javascript

You can refactor this logic into a calculateSignature function that accepts a secret and a message and returns a signature:

function calculateSignature(secret, message) {
// Decode the client secret
const key = Buffer.from(secret, "base64");
// Generate the signature
return createHmac("sha256", key).update(message).digest("hex");
}
javascript

You can then use this function to calculate the signature for POST and GET requests.

When Canva sends a POST request to an app, it includes a comma-separated list of request signatures in the X-Canva-Signatures header.

If the calculated signature is not included in the list of signatures, the request did not originate from Canva and the app must reject the request with a 401 status code:

// Load the client secret from an environment variable
const secret = process.env.CLIENT_SECRET;
// Construct the message
const version = "v1";
const timestamp = request.header("X-Canva-Timestamp");
const path = getPathForSignatureVerification(request.path);
const body = request.rawBody;
const message = `${version}:${timestamp}:${path}:${body}`;
// Calculate a signature
const signature = calculateSignature(secret, message);
// Reject requests with invalid signatures
if (!request.header("X-Canva-Signatures").includes(signature)) {
response.sendStatus(401);
return;
}
javascript

Even if an app verifies request signatures, it's still vulnerable to replay attacks. To protect itself against these types of attacks, an app must:

  1. Compare the timestamp of when the request was sent with when it was received.
  2. Verify that the timestamps are within 5 minutes (300 seconds) of one another.

When the timestamps are not within 5 minutes of one another, the app must reject the request by returning a 401 status code.

In a POST request, an app can access the UNIX timestamp (in seconds) of when Canva sent the request via the X-Canva-Timestamp HTTP header.

The following snippet demonstrates how to create an isValidTimestamp function that checks if two timestamps are within 300 seconds of each other and rejects the request if they're not:

function isValidTimestamp(
sentAtSeconds,
receivedAtSeconds,
leniencyInSeconds = 300
) {
return (
Math.abs(Number(sentAtSeconds) - Number(receivedAtSeconds)) <
Number(leniencyInSeconds)
);
}
const sentAtSeconds = request.header("X-Canva-Timestamp");
const receivedAtSeconds = new Date().getTime() / 1000;
// Verify the timestamp of a POST request
if (!isValidTimestamp(sentAtSeconds, receivedAtSeconds)) {
response.sendStatus(401);
return;
}
javascript
const { createHmac } = require("crypto");
const express = require("express");
const app = express();
app.use(
express.json({
verify: (request, response, buffer) => {
request.rawBody = buffer.toString();
},
})
);
app.post("/content/resources/find", async (request, response) => {
if (!isValidPostRequest(process.env.CLIENT_SECRET, request)) {
response.sendStatus(401);
return;
}
response.send({
type: "SUCCESS",
resources: [],
});
});
const isValidPostRequest = (secret, request) => {
// Verify the timestamp
const sentAtSeconds = request.header("X-Canva-Timestamp");
const receivedAtSeconds = new Date().getTime() / 1000;
if (!isValidTimestamp(sentAtSeconds, receivedAtSeconds)) {
return false;
}
// Construct the message
const version = "v1";
const timestamp = request.header("X-Canva-Timestamp");
const path = getPathForSignatureVerification(request.path);
const body = request.rawBody;
const message = `${version}:${timestamp}:${path}:${body}`;
// Calculate a signature
const signature = calculateSignature(secret, message);
// Reject requests with invalid signatures
if (!request.header("X-Canva-Signatures").includes(signature)) {
return false;
}
return true;
};
const isValidTimestamp = (
sentAtSeconds,
receivedAtSeconds,
leniencyInSeconds = 300
) => {
return (
Math.abs(Number(sentAtSeconds) - Number(receivedAtSeconds)) <
Number(leniencyInSeconds)
);
};
const getPathForSignatureVerification = (input) => {
const paths = [
"/configuration",
"/configuration/delete",
"/content/resources/find",
"/publish/resources/find",
"/publish/resources/get",
"/publish/resources/upload",
];
return paths.find((path) => input.endsWith(path));
};
const calculateSignature = (secret, message) => {
// Decode the client secret
const key = Buffer.from(secret, "base64");
// Calculate the signature
return createHmac("sha256", key).update(message).digest("hex");
};
app.listen(process.env.PORT || 3000);
javascript