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 site 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).

#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) to generate an RFC 9421-compliant signature:

npm install http-message-sig

Build and send a signed request like this:

import crypto from 'node:crypto';
import { signatureHeadersSync } from 'http-message-sig';

const method = 'POST';
const url = 'https://my-env.some-domain.com/api';

const body = JSON.stringify({
  query: `{ entries(section: "blog") { title url } }`,
});

const created = new Date();

const signer = {
  keyid: 'hmac',
  alg: 'hmac-sha256',
  signSync(data) {
    return crypto
      .createHmac('sha256', process.env.CRAFT_CLOUD_SIGNING_KEY)
      .update(data)
      .digest();
  },
};

const signatureHeaders = signatureHeadersSync(
  { method, url },
  {
    key: 'sig',
    signer,
    components: ['@method', '@target-uri'],
    created,

    // Optional 60-second expiry. The maximum is five minutes.
    expires: new Date(created.getTime() + 60 * 1000),
  }
);

await fetch(url, {
  method,
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer my-secret-gql-schema-token',
    ...signatureHeaders,
  },
  body,
});

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 url variable for the signed request 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).