Request Signing
Request signing allows trusted systems to make programmatic requests to Craft Cloud without being treated like unsanctioned bot traffic.
This is useful for automated systems like static app builds or CI/CD pipelines, which will often be identified (correctly!) as “bots” and be rate-limited more aggressively than browsers.
Each environment’s $CRAFT_CLOUD_SIGNING_KEY system variable is used as a shared secret when generating and validating signed requests.
For more details on RFC 9421 HTTP Message Signatures, see httpsig.org (opens new window).
If you are building a headless app, also follow the Headless Apps guide for required retry and caching behavior.
#Creating a Signed Request
External systems can generate valid signatures for a Craft Cloud environment, provided the corresponding $CRAFT_CLOUD_SIGNING_KEY.
Signatures are valid at the Craft Cloud gateway for a maximum of five minutes. A signed request is not consumed (like a token URL is, in Craft), and they are not idempotent.
#From Node.js
This example uses http-message-sig (opens new window) for convenience, but the package is not required. You may use any RFC 9421-compatible implementation.
Create a reusable request-signatures.js helper:
import crypto from 'node:crypto';
import { signatureHeadersSync } from 'http-message-sig';
const { CRAFT_CLOUD_SIGNING_KEY } = process.env;
if (!CRAFT_CLOUD_SIGNING_KEY) {
throw new Error('CRAFT_CLOUD_SIGNING_KEY is not set');
}
export function getSignatureHeaders(
request,
components = ['@method', '@target-uri']
) {
const created = new Date();
return signatureHeadersSync(
request,
{
key: 'sig',
signer: {
keyid: 'hmac',
alg: 'hmac-sha256',
signSync(data) {
return crypto
.createHmac('sha256', CRAFT_CLOUD_SIGNING_KEY)
.update(data)
.digest();
},
},
components,
created,
// Optional expiry. The maximum is five minutes.
// expires: new Date(created.getTime() + 60 * 1000),
}
);
}
Pass additional covered components (opens new window), such as content-type, in the second argument when those values must also be signed.
Import the helper when sending a signed request:
import { getSignatureHeaders } from './request-signatures.js';
const request = {
method: 'POST',
url: 'https://my-env.some-domain.com/api',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer my-secret-gql-schema-token',
},
};
const body = JSON.stringify({
query: `{ entries(section: "blog") { title url } }`,
});
const signatureHeaders = getSignatureHeaders(request);
const response = await fetch(request.url, {
method: request.method,
headers: {
...request.headers,
...signatureHeaders,
},
body,
});
if (!response.ok) {
throw new Error(`Craft request failed: ${response.status}`);
}
Requests signed using the @target-uri component (opens new window) are only valid when sent to a URL that matches exactly, including the scheme, hostname, path, and query string.
The example above satisfies this by using the same request.url value for signing and the fetch() call.
#From Grafana Cloud k6
This example uses Grafana Cloud k6 (opens new window) with native dependencies:
import crypto from 'k6/crypto';
import http from 'k6/http';
const method = 'POST';
const url = 'https://my-env.some-domain.com/api';
const body = JSON.stringify({
query: `{ entries(section: "blog") { title url } }`,
});
export default function () {
const created = Math.floor(Date.now() / 1000);
const expires = created + 60;
const signatureParams = [
'("@method" "@target-uri")',
`created=${created}`,
`expires=${expires}`,
'keyid="hmac"',
'alg="hmac-sha256"',
].join(';');
const signatureBase = [
['@method', method],
['@target-uri', url],
['@signature-params', signatureParams],
]
.map(([component, value]) => `"${component}": ${value}`)
.join('\n');
const signature = crypto.hmac(
'sha256',
__ENV.CRAFT_CLOUD_SIGNING_KEY,
signatureBase,
'base64'
);
http.post(url, body, {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer my-secret-gql-schema-token',
'Signature-Input': `sig=${signatureParams}`,
'Signature': `sig=:${signature}:`,
},
});
}
#From Craft
Any Craft project running on Cloud can sign requests. This can be useful when making HTTP requests from a console command, queue job, or for communication between environments or projects.
use Craft;
use craft\cloud\Module;
use GuzzleHttp\Psr7\Request;
$signer = Module::getInstance()->getRequestSigner();
$request = new Request(
'POST',
'https://api.example.test/webhook',
['Content-Type' => 'application/json'],
json_encode([
'event' => 'order.paid',
], JSON_THROW_ON_ERROR),
);
$signedRequest = $signer->sign($request);
$response = Craft::createGuzzleClient()->send($signedRequest);
#Signature Verification
Craft Cloud automatically tries to validate signed requests, at the gateway. If validation fails, normal bot- and rate-limiting rules are applied; if no policies are triggered, the request is forwarded to Craft like any other.
Once the request reaches your application, you are free to perform additional verification (like checking a separate shared secret).