Verify GET request signatures

Learn how to verify the request signatures of GET requests.

When Canva sends a GET request to an app, it includes a comma-separated list of request signatures in the signatures query parameter.

A request signature is a unique string of characters 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 found in the comma-separated list of signatures.
  3. Return a 401 status code if the calculated signature is not 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 GET 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. You can access this timestamp via the time query parameter.

The ID of the user. You can access this ID via the user query parameter.

The ID of the user's brand. You can access this ID via the brand query parameter.

A comma-separated list of extension types, such as CONTENT or PUBLISH. You can access this list via the extensions query parameter.

A unique token for protecting an app against CSRF attacks. You can access this token via the state query parameter.

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

const express = require("express");
const app = express();
app.get("/my-redirect-url", async (request, response) => {
const version = "v1";
const { time, user, brand, extensions, state } = request.query;
const message = `${version}:${time}:${user}:${brand}:${extensions}:${state}`;
console.log(message);
});
app.listen(process.env.PORT || 3000);
javascript

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

v1:1586167939:AQy_Xvglh9cbgHk97BqOiRscRk98Vm-Fjytfs9X-68s=:AQy_XvgNXCsnKeFtcD5-L-VBg_ngJepbEhGYBVmCo6E=:CONTENT:95a5aa62-0713-4ae4-b99f-8efa57e7def0

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 GET and POST requests.

When Canva sends a GET request to an app, it includes a comma-separated list of request signatures in the signatures query parameter.

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 { time, user, brand, extensions, state } = request.query;
const message = `${version}:${time}:${user}:${brand}:${extensions}:${state}`;
// Calculate a signature
const signature = calculateSignature(secret, message);
// Reject requests with invalid signatures
if (!request.query.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 GET request, an app can access the UNIX timestamp (in seconds) of when Canva sent the request via the time query parameter.

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.query.time;
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.get("/my-redirect-url", async (request, response) => {
if (!isValidGetRequest(process.env.CLIENT_SECRET, request)) {
response.sendStatus(401);
return;
}
response.sendStatus(200);
});
const isValidGetRequest = (secret, request) => {
// Verify the timestamp
const sentAtSeconds = request.query.time;
const receivedAtSeconds = new Date().getTime() / 1000;
if (!isValidTimestamp(sentAtSeconds, receivedAtSeconds)) {
return false;
}
// Construct the message
const version = "v1";
const { time, user, brand, extensions, state } = request.query;
const message = `${version}:${time}:${user}:${brand}:${extensions}:${state}`;
// Calculate a signature
const signature = calculateSignature(secret, message);
// Reject requests with invalid signatures
if (!request.query.signatures.includes(signature)) {
return false;
}
return true;
};
const isValidTimestamp = (
sentAtSeconds,
receivedAtSeconds,
leniencyInSeconds = 300
) => {
return (
Math.abs(Number(sentAtSeconds) - Number(receivedAtSeconds)) <
Number(leniencyInSeconds)
);
};
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