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.

Canva provides a list of signatures — rather than just one signature — to support key rotation. This lets you regenerate client secrets without causing downtime.

Step 1: Get your app's client secret

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. Select Verification.

  3. Under the Client secret heading, select Copy.

Never share a client secret or commit it to source control.

We recommend loading the client secret via an environment variable.

Step 2: Decode your app's client secret

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);

To learn how to decode a client secret in different programming languages, refer to Decoding a client secret.

Step 3: Create a message

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:

Version

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

Timestamp

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

The names of the HTTP headers are sometimes lowercase (e.g. x-canva-timestamp).

Path

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

  • /editing/image/process

  • /editing/image/process/get

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

  • /api/editing/image/process

Body

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

Some web frameworks, such as Express.js, automatically deserialize incoming request bodies. You need to bypass this functionality to access the raw body of the request.

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',
'/editing/image/process',
'/editing/image/process/get',
'/publish/resources/find',
'/publish/resources/get',
'/publish/resources/upload',
];
return paths.find((path) => input.endsWith(path));
};
app.listen(process.env.PORT || 3000);

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"}

Step 4: Calculate the request signature

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);

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');
}

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

Step 5: Compare the signatures

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;
}

Step 6: Verify the timestamp

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;
}

The leniency of 5 minutes accounts for the fact that requests are not instantaneous and server clocks may not be perfectly synchronized.

Example

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',
'/editing/image/process',
'/editing/image/process/get',
'/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);